Import or export products CSV.
@@ -358,7 +366,7 @@
form.append('file', file);
const res = await fetch(IMS_API.BASE_URL + '/products/import', {
method: 'POST',
- headers: { Authorization: `Bearer ${state.token}` },
+ headers: authHeaders(),
body: form
});
if (!res.ok) throw new Error('Import failed');
@@ -367,7 +375,7 @@
on($('#exportCsv'), 'click', async () => {
const res = await fetch(IMS_API.BASE_URL + '/products/export', {
- headers: { Authorization: `Bearer ${state.token}` }
+ headers: authHeaders(),
});
if (!res.ok) throw new Error('Export failed');
const csv = await res.text();
@@ -446,8 +454,7 @@
method: 'PUT',
body: JSON.stringify({ id: parseInt(id, 10), name, price, quantityInStock: qty, supplier, categoryId, description: desc })
});
- status.style.color = '#16a34a';
- status.textContent = 'Product updated.';
+ setStatus(status, 'Product updated.', 'success');
state.editingProductId = null;
$('#cancelProduct').classList.add('hidden');
$('#productFormTitle').textContent = 'Add Product';
@@ -456,14 +463,12 @@
method: 'POST',
body: JSON.stringify({ name, price, quantityInStock: qty, supplier, categoryId, description: desc })
});
- status.style.color = '#16a34a';
- status.textContent = 'Product created.';
+ setStatus(status, 'Product created.', 'success');
resetProductForm();
}
await loadProducts();
} catch (err) {
- status.style.color = '#dc2626';
- status.textContent = err.message;
+ setStatus(status, err.message, 'error');
} finally {
$('#saveProduct').disabled = false;
}
@@ -541,8 +546,7 @@
method: 'PUT',
body: JSON.stringify({ id: parseInt(id, 10), name, description })
});
- status.style.color = '#16a34a';
- status.textContent = 'Category updated.';
+ setStatus(status, 'Category updated.', 'success');
state.editingCategoryId = null;
$('#cancelCategory').classList.add('hidden');
$('#categoryFormTitle').textContent = 'Add Category';
@@ -551,14 +555,12 @@
method: 'POST',
body: JSON.stringify({ name, description })
});
- status.style.color = '#16a34a';
- status.textContent = 'Category created.';
+ setStatus(status, 'Category created.', 'success');
resetCategoryForm();
}
await loadCategories();
} catch (err) {
- status.style.color = '#dc2626';
- status.textContent = err.message;
+ setStatus(status, err.message, 'error');
} finally {
$('#saveCategory').disabled = false;
}
@@ -623,13 +625,11 @@
method: 'POST',
body: JSON.stringify({ productId, productName: product.name, quantity: qty, type, totalAmount: product.price * qty })
});
- status.style.color = '#16a34a';
- status.textContent = 'Transaction added.';
+ setStatus(status, 'Transaction added.', 'success');
$('#tQty').value = 1;
await loadTransactions();
} catch (err) {
- status.style.color = '#dc2626';
- status.textContent = err.message;
+ setStatus(status, err.message, 'error');
} finally {
$('#addTransaction').disabled = false;
}
diff --git a/IMS.Infrastructure/IMS.Infrastructure.csproj b/IMS.Infrastructure/IMS.Infrastructure.csproj
index b4e32f5..22b30b6 100644
--- a/IMS.Infrastructure/IMS.Infrastructure.csproj
+++ b/IMS.Infrastructure/IMS.Infrastructure.csproj
@@ -1,31 +1,27 @@
-
- net8.0
- enable
- enable
-
+
+
+
-
-
-
- all
runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
-
-
-
-
-
+
+ net8.0
+ enable
+ enable
+
+
PreserveNewest
diff --git a/IMS.WebClient/css/styles.css b/IMS.WebClient/css/styles.css
new file mode 100644
index 0000000..992d0cb
--- /dev/null
+++ b/IMS.WebClient/css/styles.css
@@ -0,0 +1,240 @@
+:root {
+ --bg: #f3f4f6;
+ --surface: #ffffff;
+ --text: #111827;
+ --muted: #6b7280;
+ --border: #e5e7eb;
+ --primary: #2563eb;
+ --primary-600: #1d4ed8;
+ --success: #16a34a;
+ --danger: #dc2626;
+ --warning: #f59e0b;
+ --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 6px 18px rgba(0,0,0,0.06);
+ --radius: 14px;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ height: 100%;
+}
+
+body {
+ font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, sans-serif;
+ margin: 0;
+ padding: 18px;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.45;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+.hidden {
+ display: none !important;
+}
+
+.header {
+ background: #0f172a;
+ color: #fff;
+ padding: 16px 22px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 14px;
+ border-bottom: 1px solid rgba(255,255,255,0.08);
+}
+
+.header .brand {
+ font-weight: 800;
+ font-size: 18px;
+ letter-spacing: 0.2px;
+}
+
+.header .user {
+ font-size: 13px;
+ opacity: 0.85;
+}
+
+.container {
+ max-width: 1140px;
+ margin: 24px auto;
+ padding: 0 18px;
+}
+
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 20px;
+ margin-bottom: 16px;
+ box-shadow: var(--shadow);
+}
+
+.title {
+ font-size: 22px;
+ font-weight: 800;
+ margin: 0 0 14px;
+ color: #0f172a;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 24px;
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ border: none;
+ padding: 10px 14px;
+ border-radius: 12px;
+ cursor: pointer;
+ font-weight: 700;
+ font-size: 14px;
+ color: #fff;
+ background: var(--primary);
+ transition: transform 0.08s ease, background 0.2s ease, box-shadow 0.2s ease;
+}
+
+.btn:hover { background: var(--primary-600); }
+.btn:active { transform: translateY(1px); }
+
+.btn.secondary { background: #6b7280; }
+.btn.secondary:hover { background: #4b5563; }
+
+.btn.success { background: var(--success); }
+.btn.success:hover { background: #15803d; }
+
+.btn.danger { background: var(--danger); }
+.btn.danger:hover { background: #b91c1c; }
+
+.btn.ghost {
+ background: #eef2ff;
+ color: #1e3a8a;
+}
+.btn.ghost:hover { background: #e0e7ff; }
+
+input, select, textarea {
+ width: 100%;
+ padding: 10px 11px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: #fff;
+ font: inherit;
+ color: var(--text);
+ outline: none;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+input:focus, select:focus, textarea:focus {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
+}
+
+label {
+ display: block;
+ font-size: 13px;
+ font-weight: 700;
+ color: #374151;
+ margin: 10px 10px 6px;
+}
+
+.form-grid > div {
+ margin-bottom: 14px;
+ padding: 0 6px;
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 12px;
+ background: var(--surface);
+ border-radius: 12px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+}
+
+.table th, .table td {
+ padding: 12px 13px;
+ text-align: left;
+ border-bottom: 1px solid #f3f4f6;
+ font-size: 14px;
+}
+
+.table th {
+ background: #f9fafb;
+ font-weight: 700;
+ color: #374151;
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+}
+
+.table tbody tr:hover {
+ background: #fafafa;
+}
+
+.login {
+ max-width: 420px;
+ margin: 80px auto;
+ padding: 24px;
+}
+
+.status {
+ margin-top: 10px;
+ font-size: 14px;
+ color: var(--danger);
+}
+
+.status.success {
+ color: var(--success);
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: center;
+}
+
+.actions-right {
+ margin-left: auto;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 700;
+ background: #eef2ff;
+ color: #1e3a8a;
+}
+
+.empty {
+ text-align: center;
+ color: var(--muted);
+ padding: 28px 10px;
+ font-size: 14px;
+}
+
+@media (max-width: 640px) {
+ .header {
+ padding: 14px 16px;
+ }
+ .container {
+ margin: 16px auto;
+ padding: 0 14px;
+ }
+ .title {
+ font-size: 20px;
+ }
+}
diff --git a/IMS.WebClient/index.html b/IMS.WebClient/index.html
new file mode 100644
index 0000000..a916c77
--- /dev/null
+++ b/IMS.WebClient/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ IMS Web
+
+
+
+
+
+
+
+
diff --git a/IMS.WebClient/js/app.js b/IMS.WebClient/js/app.js
new file mode 100644
index 0000000..0c9420c
--- /dev/null
+++ b/IMS.WebClient/js/app.js
@@ -0,0 +1,710 @@
+(() => {
+ const $ = (sel) => document.querySelector(sel);
+ const on = (el, ev, fn) => el.addEventListener(ev, fn);
+ const canManage = () => state.role === 'Admin' || state.role === 'Manager';
+
+ const state = {
+ email: localStorage.getItem(IMS_API.EMAIL_KEY) || '',
+ token: localStorage.getItem(IMS_API.TOKEN_KEY) || '',
+ role: localStorage.getItem(IMS_API.ROLE_KEY) || '',
+ products: [],
+ categories: [],
+ transactions: [],
+ editingCategoryId: null,
+ editingProductId: null
+ };
+
+ const authHeaders = () => ({
+ 'Content-Type': 'application/json',
+ ...(state.token ? { Authorization: `Bearer ${state.token}` } : {})
+ });
+
+ const api = async (path, opts = {}) => {
+ const res = await fetch(IMS_API.BASE_URL + path, {
+ ...opts,
+ headers: {
+ ...authHeaders(),
+ ...(opts.headers || {})
+ }
+ });
+
+ if (res.status === 401) {
+ logout();
+ throw new Error('Unauthorized');
+ }
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || `HTTP ${res.status}`);
+ }
+
+ if (res.status === 204) return null;
+
+ const contentType = res.headers.get('content-type') || '';
+ if (contentType.includes('application/json')) return res.json();
+ return res.text();
+ };
+
+ const setAuth = (data) => {
+ state.token = data.token;
+ state.email = data.email;
+ state.role = (data.roles || [])[0] || '';
+ localStorage.setItem(IMS_API.TOKEN_KEY, data.token);
+ localStorage.setItem(IMS_API.EMAIL_KEY, data.email);
+ localStorage.setItem(IMS_API.ROLE_KEY, state.role);
+ };
+
+ const logout = () => {
+ state.token = '';
+ state.email = '';
+ state.role = '';
+ localStorage.removeItem(IMS_API.TOKEN_KEY);
+ localStorage.removeItem(IMS_API.EMAIL_KEY);
+ localStorage.removeItem(IMS_API.ROLE_KEY);
+ };
+
+ const show = (id) => {
+ document.querySelectorAll('#app > section').forEach((el) => el.classList.add('hidden'));
+ const target = document.getElementById(id);
+ if (target) target.classList.remove('hidden');
+ };
+
+ const esc = (val) => {
+ if (val == null) return '';
+ return String(val)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ };
+
+ const renderTable = (containerId, rows, columns, actions) => {
+ const container = document.getElementById(containerId);
+ if (!container) return;
+ if (!rows.length) {
+ container.innerHTML = 'No records yet.
';
+ return;
+ }
+
+ let html = `
+
+
+
+
+ ${columns.map((col) => `| ${esc(col.label)} | `).join('')}
+ ${actions && canManage() ? 'Actions | ' : ''}
+
+
+
+ ${rows.map((row) => `
+
+ ${columns.map((col) => `| ${esc(col.value ? col.value(row) : row[col.key])} | `).join('')}
+ ${actions && canManage() ? `${actions(row)} | ` : ''}
+
+ `).join('')}
+
+
+
+ `;
+ container.innerHTML = html;
+ };
+
+ const confirm = (message) => window.confirm(message || 'Are you sure?');
+
+ const setStatus = (el, message, type = 'error') => {
+ if (!el) return;
+ el.textContent = message || '';
+ el.className = 'status ' + (type === 'success' ? 'success' : '');
+ };
+
+ const loginPage = () => `
+
+ `;
+
+ const dashboardPage = () => {
+ const canManage = state.role === 'Admin' || state.role === 'Manager';
+ return `
+
+
+
+
+
+
📦 Products
+
Create, update, delete products.
+
+
+ ${canManage ? `` : ''}
+
+
+
+
🧾 Transactions
+
Record sales and purchases.
+
+
+
+
+
+
📁 Data
+
Import or export products CSV.
+
+
+
+
+
+
+
+
+ `;
+ };
+
+ const productsPage = () => `
+
+
+
+
+
Add Product
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const categoriesPage = () => `
+
+
+
+
+
Add Category
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const transactionsPage = () => `
+
+
+
+
+
Add Transaction
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const init = () => {
+ const app = $('#app');
+ app.innerHTML = state.token
+ ? dashboardPage() + categoriesPage() + productsPage() + transactionsPage()
+ : loginPage();
+
+ if (state.token) {
+ initDashboard();
+ initProducts();
+ initCategories();
+ initTransactions();
+ show('dashboard');
+ } else {
+ initLogin();
+ show('login');
+ }
+ };
+
+ const initLogin = () => {
+ on($('#loginBtn'), 'click', async () => {
+ const email = $('#loginEmail').value.trim();
+ const password = $('#loginPassword').value.trim();
+ const errorEl = $('#loginError');
+ errorEl.textContent = '';
+
+ if (!email || !password) {
+ errorEl.textContent = 'Enter email and password.';
+ return;
+ }
+
+ $('#loginBtn').disabled = true;
+ $('#loginBtn').textContent = 'Logging in...';
+
+ try {
+ const data = await api('/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ email, password })
+ });
+
+ setAuth(data);
+ init();
+ } catch (err) {
+ errorEl.textContent = err.message || 'Login failed.';
+ } finally {
+ $('#loginBtn').disabled = false;
+ $('#loginBtn').textContent = 'Login';
+ }
+ });
+ };
+
+ const initDashboard = () => {
+ on($('#logoutBtn'), 'click', () => {
+ logout();
+ init();
+ });
+
+ document.querySelectorAll('.open-page').forEach((btn) => {
+ on(btn, 'click', async () => {
+ const page = btn.dataset.page;
+ show(page);
+ if (page === 'products') await loadProducts();
+ if (page === 'categories') await loadCategories();
+ if (page === 'transactions') await loadTransactions();
+ });
+ });
+
+ const doImport = async (file) => {
+ const form = new FormData();
+ form.append('file', file);
+ const res = await fetch(IMS_API.BASE_URL + '/products/import', {
+ method: 'POST',
+ headers: authHeaders(),
+ body: form
+ });
+ if (!res.ok) throw new Error('Import failed');
+ alert('Import completed.');
+ };
+
+ on($('#exportCsv'), 'click', async () => {
+ const res = await fetch(IMS_API.BASE_URL + '/products/export', {
+ headers: authHeaders(),
+ });
+ if (!res.ok) throw new Error('Export failed');
+ const csv = await res.text();
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'products_export.csv';
+ a.click();
+ URL.revokeObjectURL(url);
+ });
+
+ on($('#importCsv'), 'change', async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ try {
+ $('#importCsv').disabled = true;
+ await doImport(file);
+ await loadProducts();
+ } catch (err) {
+ alert(err.message);
+ } finally {
+ $('#importCsv').disabled = false;
+ $('#importCsv').value = '';
+ }
+ });
+ };
+
+ const initProducts = () => {
+ const setProductForm = ({ id, name, price, quantityInStock, supplier, categoryId, description }) => {
+ $('#pId').value = id || '';
+ $('#pName').value = name || '';
+ $('#pPrice').value = price ?? '';
+ $('#pQty').value = quantityInStock ?? '';
+ $('#pSupplier').value = supplier || '';
+ $('#pDesc').value = description || '';
+ $('#pCategory').value = categoryId || '';
+ $('#productFormTitle').textContent = id ? 'Edit Product' : 'Add Product';
+ $('#cancelProduct').classList.toggle('hidden', !id);
+ };
+
+ const resetProductForm = () => {
+ state.editingProductId = null;
+ $('#pId').value = '';
+ $('#pName').value = '';
+ $('#pPrice').value = '';
+ $('#pQty').value = '';
+ $('#pSupplier').value = '';
+ $('#pDesc').value = '';
+ $('#pCategory').value = '';
+ $('#productStatus').textContent = '';
+ $('#productFormTitle').textContent = 'Add Product';
+ $('#cancelProduct').classList.add('hidden');
+ };
+
+ on($('#saveProduct'), 'click', async () => {
+ const id = $('#pId').value.trim();
+ const name = $('#pName').value.trim();
+ const price = parseFloat($('#pPrice').value);
+ const qty = parseInt($('#pQty').value, 10);
+ const supplier = $('#pSupplier').value.trim();
+ const categoryId = parseInt($('#pCategory').value, 10);
+ const desc = $('#pDesc').value.trim();
+ const status = $('#productStatus');
+ status.textContent = '';
+
+ if (!name || Number.isNaN(price) || Number.isNaN(qty) || Number.isNaN(categoryId)) {
+ status.textContent = 'Fill required product fields.';
+ return;
+ }
+
+ $('#saveProduct').disabled = true;
+ try {
+ if (id) {
+ await api(`/products/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ id: parseInt(id, 10), name, price, quantityInStock: qty, supplier, categoryId, description: desc })
+ });
+ setStatus(status, 'Product updated.', 'success');
+ state.editingProductId = null;
+ $('#cancelProduct').classList.add('hidden');
+ $('#productFormTitle').textContent = 'Add Product';
+ } else {
+ await api('/products', {
+ method: 'POST',
+ body: JSON.stringify({ name, price, quantityInStock: qty, supplier, categoryId, description: desc })
+ });
+ setStatus(status, 'Product created.', 'success');
+ resetProductForm();
+ }
+ await loadProducts();
+ } catch (err) {
+ setStatus(status, err.message, 'error');
+ } finally {
+ $('#saveProduct').disabled = false;
+ }
+ });
+
+ on($('#resetProduct'), 'click', () => resetProductForm());
+ on($('#cancelProduct'), 'click', () => resetProductForm());
+
+ on($('#productsTable'), 'click', async (e) => {
+ const btn = e.target.closest('button[data-action]');
+ if (!btn) return;
+ const action = btn.dataset.action;
+ const productId = btn.dataset.id;
+ if (!productId) return;
+
+ const product = state.products.find((p) => p.id == productId);
+ if (!product) return;
+
+ if (action === 'editProduct' && product) {
+ state.editingProductId = product.id;
+ setProductForm(product);
+ $('#pName').focus();
+ return;
+ }
+
+ if (action === 'deleteProduct' && product) {
+ if (!confirm(`Delete product "${product.name}"?`)) return;
+ try {
+ await api(`/products/${product.id}`, { method: 'DELETE' });
+ await loadProducts();
+ if (state.editingProductId === product.id) resetProductForm();
+ } catch (err) {
+ $('#productStatus').style.color = '#dc2626';
+ $('#productStatus').textContent = err.message;
+ }
+ }
+ });
+ };
+
+ const initCategories = () => {
+ const setCategoryForm = ({ id, name, description }) => {
+ $('#cId').value = id || '';
+ $('#cName').value = name || '';
+ $('#cDesc').value = description || '';
+ $('#categoryFormTitle').textContent = id ? 'Edit Category' : 'Add Category';
+ $('#cancelCategory').classList.toggle('hidden', !id);
+ };
+
+ const resetCategoryForm = () => {
+ state.editingCategoryId = null;
+ $('#cId').value = '';
+ $('#cName').value = '';
+ $('#cDesc').value = '';
+ $('#categoryStatus').textContent = '';
+ $('#categoryFormTitle').textContent = 'Add Category';
+ $('#cancelCategory').classList.add('hidden');
+ };
+
+ on($('#saveCategory'), 'click', async () => {
+ const id = $('#cId').value.trim();
+ const name = $('#cName').value.trim();
+ const description = $('#cDesc').value.trim();
+ const status = $('#categoryStatus');
+ status.textContent = '';
+
+ if (!name) {
+ status.textContent = 'Category name is required.';
+ return;
+ }
+
+ $('#saveCategory').disabled = true;
+ try {
+ if (id) {
+ await api(`/categories/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ id: parseInt(id, 10), name, description })
+ });
+ setStatus(status, 'Category updated.', 'success');
+ state.editingCategoryId = null;
+ $('#cancelCategory').classList.add('hidden');
+ $('#categoryFormTitle').textContent = 'Add Category';
+ } else {
+ await api('/categories', {
+ method: 'POST',
+ body: JSON.stringify({ name, description })
+ });
+ setStatus(status, 'Category created.', 'success');
+ resetCategoryForm();
+ }
+ await loadCategories();
+ } catch (err) {
+ setStatus(status, err.message, 'error');
+ } finally {
+ $('#saveCategory').disabled = false;
+ }
+ });
+
+ on($('#resetCategory'), 'click', () => resetCategoryForm());
+ on($('#cancelCategory'), 'click', () => resetCategoryForm());
+
+ on($('#categoriesTable'), 'click', async (e) => {
+ const btn = e.target.closest('button[data-action]');
+ if (!btn) return;
+ const action = btn.dataset.action;
+ const categoryId = btn.dataset.id;
+ if (!categoryId) return;
+
+ const category = state.categories.find((c) => c.id == categoryId);
+ if (!category) return;
+
+ if (action === 'editCategory' && category) {
+ state.editingCategoryId = category.id;
+ setCategoryForm(category);
+ $('#cName').focus();
+ return;
+ }
+
+ if (action === 'deleteCategory' && category) {
+ if (!confirm(`Delete category "${category.name}"? This may affect linked products.`)) return;
+ try {
+ await api(`/categories/${category.id}`, { method: 'DELETE' });
+ await loadCategories();
+ if (state.editingCategoryId === category.id) resetCategoryForm();
+ } catch (err) {
+ $('#categoryStatus').style.color = '#dc2626';
+ $('#categoryStatus').textContent = err.message;
+ }
+ }
+ });
+ };
+
+ const initTransactions = () => {
+ on($('#addTransaction'), 'click', async () => {
+ const productId = parseInt($('#tProduct').value, 10);
+ const qty = parseInt($('#tQty').value, 10);
+ const type = $('#tType').value;
+ const status = $('#transactionStatus');
+ status.textContent = '';
+
+ if (Number.isNaN(productId) || Number.isNaN(qty) || qty < 1) {
+ status.textContent = 'Select a product and quantity.';
+ return;
+ }
+
+ const product = state.products.find((p) => p.id === productId);
+ if (!product) {
+ status.textContent = 'Selected product not found.';
+ return;
+ }
+
+ $('#addTransaction').disabled = true;
+ try {
+ await api('/transactions', {
+ method: 'POST',
+ body: JSON.stringify({ productId, productName: product.name, quantity: qty, type, totalAmount: product.price * qty })
+ });
+ setStatus(status, 'Transaction added.', 'success');
+ $('#tQty').value = 1;
+ await loadTransactions();
+ } catch (err) {
+ setStatus(status, err.message, 'error');
+ } finally {
+ $('#addTransaction').disabled = false;
+ }
+ });
+
+ on($('#refreshTransactions'), 'click', () => loadTransactions());
+ };
+
+ const loadCategories = async () => {
+ try {
+ const categories = await api('/categories');
+ state.categories = categories;
+ renderTable('categoriesTable', categories, [
+ { key: 'id', label: 'ID' },
+ { key: 'name', label: 'Name' },
+ { key: 'description', label: 'Description', value: (c) => c.description || '' }
+ ], (category) => {
+ if (!category) return '';
+ return `
+
+
+
+
+ `;
+ });
+ } catch (err) {
+ $('#categoriesTable').innerHTML = `${esc(err.message)}
`;
+ }
+ };
+
+ const loadProducts = async () => {
+ try {
+ state.products = await api('/products');
+ state.categories = await api('/categories');
+ const catSelect = $('#pCategory');
+ catSelect.innerHTML = state.categories.map((c) => ``).join('');
+ renderTable('productsTable', state.products, [
+ { key: 'id', label: 'ID' },
+ { key: 'name', label: 'Name' },
+ { key: 'price', label: 'Price', value: (p) => p.price.toFixed(2) },
+ { key: 'quantityInStock', label: 'Qty' },
+ { key: 'supplier', label: 'Supplier', value: (p) => p.supplier || '' },
+ { key: 'categoryId', label: 'Category', value: (p) => (state.categories.find((c) => c.id === p.categoryId) || {}).name || p.categoryId }
+ ], (product) => {
+ if (!product) return '';
+ return `
+
+
+
+
+ `;
+ });
+ } catch (err) {
+ $('#productsTable').innerHTML = `${esc(err.message)}
`;
+ }
+ };
+
+ const loadTransactions = async () => {
+ try {
+ state.products = await api('/products');
+ state.transactions = await api('/transactions');
+ const productSelect = $('#tProduct');
+ productSelect.innerHTML = state.products.map((p) => ``).join('');
+ renderTable('transactionsTable', state.transactions, [
+ { key: 'id', label: 'ID' },
+ { key: 'productName', label: 'Product' },
+ { key: 'type', label: 'Type' },
+ { key: 'quantity', label: 'Qty' },
+ { key: 'totalAmount', label: 'Amount', value: (t) => Number(t.totalAmount).toFixed(2) },
+ { key: 'date', label: 'Date', value: (t) => new Date(t.date).toLocaleString() }
+ ]);
+ } catch (err) {
+ $('#transactionsTable').innerHTML = `${esc(err.message)}
`;
+ }
+ };
+
+ init();
+})();
diff --git a/IMS.WebClient/js/config.js b/IMS.WebClient/js/config.js
new file mode 100644
index 0000000..25a90c2
--- /dev/null
+++ b/IMS.WebClient/js/config.js
@@ -0,0 +1,11 @@
+window.IMS_API = {
+ BASE_URL: (() => {
+ const fromMeta = document.querySelector('meta[name="ims-api-base"]')?.getAttribute('content');
+ const fromQuery = new URLSearchParams(window.location.search).get('api');
+ const fromEnv = window.__IMS_API_BASE__;
+ return fromMeta || fromQuery || fromEnv || '/api';
+ })(),
+ TOKEN_KEY: 'ims_token',
+ EMAIL_KEY: 'ims_email',
+ ROLE_KEY: 'ims_role'
+};
diff --git a/fix_auth.py b/fix_auth.py
new file mode 100644
index 0000000..7ec51ce
--- /dev/null
+++ b/fix_auth.py
@@ -0,0 +1,20 @@
+import os
+paths = [
+ r'C:\Users\mario\source\repos\IMS-Backend\IMS.API\wwwroot\js\app.js',
+ r'C:\Users\mario\source\repos\IMS-Backend\IMS.WebClient\js\app.js'
+]
+old = '*** ${state.token}'
+new = '`Bearer ${state.token}'
+for path in paths:
+ if not os.path.exists(path):
+ print(f'missing: {path}')
+ continue
+ with open(path, 'r', encoding='utf-8') as f:
+ s = f.read()
+ if old in s:
+ s = s.replace(old, new)
+ with open(path, 'w', encoding='utf-8') as f:
+ f.write(s)
+ print(f'fixed: {path}')
+ else:
+ print(f'noop: {path}')
From edfda889a57a74a99ca7c4401b39da944841a17e Mon Sep 17 00:00:00 2001
From: Mario Medhat <118922155+Mariomedhat899@users.noreply.github.com>
Date: Wed, 12 Aug 2026 19:54:47 +0300
Subject: [PATCH 4/9] Add API key auth, tests, and seeder defaults
---
.gitignore | 4 +-
.../Attributes/ApiKeyAuthorizeAttribute.cs | 40 ++
IMS.API/Controllers/ApiKeysController.cs | 97 ++++
IMS.API/Controllers/TestController.cs | 16 +
IMS.API/Program.cs | 1 +
IMS.Core/Entities/ApiKey.cs | 11 +
.../Data/ApplicationDbContext.cs | 1 +
IMS.Infrastructure/Data/DataSeeder.cs | 35 +-
.../20260812161702_AddApiKey.Designer.cs | 533 ++++++++++++++++++
.../Migrations/20260812161702_AddApiKey.cs | 39 ++
.../ApplicationDbContextModelSnapshot.cs | 31 +-
11 files changed, 804 insertions(+), 4 deletions(-)
create mode 100644 IMS.API/Attributes/ApiKeyAuthorizeAttribute.cs
create mode 100644 IMS.API/Controllers/ApiKeysController.cs
create mode 100644 IMS.API/Controllers/TestController.cs
create mode 100644 IMS.Core/Entities/ApiKey.cs
create mode 100644 IMS.Infrastructure/Migrations/20260812161702_AddApiKey.Designer.cs
create mode 100644 IMS.Infrastructure/Migrations/20260812161702_AddApiKey.cs
diff --git a/.gitignore b/.gitignore
index 9491a2f..9cf486b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -360,4 +360,6 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
-FodyWeavers.xsd
\ No newline at end of file
+FodyWeavers.xsd
+# Hermes Agent workspace
+.hermes/
diff --git a/IMS.API/Attributes/ApiKeyAuthorizeAttribute.cs b/IMS.API/Attributes/ApiKeyAuthorizeAttribute.cs
new file mode 100644
index 0000000..5da38bb
--- /dev/null
+++ b/IMS.API/Attributes/ApiKeyAuthorizeAttribute.cs
@@ -0,0 +1,40 @@
+using IMS.Infrastructure.Data;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.EntityFrameworkCore;
+
+namespace IMS.API.Attributes;
+
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+public class ApiKeyAuthorizeAttribute : Attribute, IAsyncActionFilter
+{
+ private const string HeaderName = "X-API-Key";
+
+ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
+ {
+ var dbContext = context.HttpContext.RequestServices.GetService();
+ if (dbContext == null)
+ {
+ context.Result = new StatusCodeResult(StatusCodes.Status500InternalServerError);
+ return;
+ }
+
+ if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out var keyValue) ||
+ string.IsNullOrWhiteSpace(keyValue))
+ {
+ context.Result = new UnauthorizedResult();
+ return;
+ }
+
+ var apiKey = await dbContext.ApiKeys
+ .FirstOrDefaultAsync(k => k.Key == keyValue.ToString() && k.IsActive);
+
+ if (apiKey is null || apiKey.ExpiresAt <= DateTime.UtcNow)
+ {
+ context.Result = new UnauthorizedResult();
+ return;
+ }
+
+ await next();
+ }
+}
diff --git a/IMS.API/Controllers/ApiKeysController.cs b/IMS.API/Controllers/ApiKeysController.cs
new file mode 100644
index 0000000..5356a09
--- /dev/null
+++ b/IMS.API/Controllers/ApiKeysController.cs
@@ -0,0 +1,97 @@
+using IMS.API.Attributes;
+using IMS.Core.Entities;
+using IMS.Infrastructure.Data;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace IMS.API.Controllers;
+
+[ApiController]
+[Route("api/apikeys")]
+public class ApiKeysController : ControllerBase
+{
+ private readonly ApplicationDbContext _db;
+ private readonly ILogger _logger;
+
+ public ApiKeysController(ApplicationDbContext db, ILogger logger)
+ {
+ _db = db;
+ _logger = logger;
+ }
+
+ [HttpPost("create")]
+ [ApiKeyAuthorize]
+ public async Task Create([FromBody] CreateApiKeyRequest request)
+ {
+ if (!Request.Headers.TryGetValue("X-API-Key", out var headerKey))
+ {
+ return Unauthorized();
+ }
+
+ var adminKey = await _db.ApiKeys
+ .FirstOrDefaultAsync(k => k.Key == headerKey.ToString() && k.IsActive);
+
+ if (adminKey is null || adminKey.ExpiresAt <= DateTime.UtcNow)
+ {
+ return Unauthorized();
+ }
+
+ if (request.ExpiresAt.Kind != DateTimeKind.Utc)
+ {
+ request.ExpiresAt = request.ExpiresAt.ToUniversalTime();
+ }
+
+ var newKey = new ApiKey
+ {
+ Key = new string(Path.GetRandomFileName().Replace(".", "").Take(32).ToArray()),
+ Owner = string.IsNullOrWhiteSpace(request.Owner) ? null : request.Owner.Trim(),
+ ExpiresAt = request.ExpiresAt
+ };
+
+ _db.ApiKeys.Add(newKey);
+ await _db.SaveChangesAsync();
+
+ return Ok(new ApiKeyResponse
+ {
+ Id = newKey.Id,
+ Key = newKey.Key,
+ Owner = newKey.Owner,
+ ExpiresAt = newKey.ExpiresAt,
+ IsActive = newKey.IsActive
+ });
+ }
+
+ [HttpGet]
+ [ApiKeyAuthorize]
+ public async Task GetAll()
+ {
+ var keys = await _db.ApiKeys
+ .OrderByDescending(k => k.CreatedAt)
+ .Select(k => new ApiKeyResponse
+ {
+ Id = k.Id,
+ Key = k.Key.Length > 6 ? k.Key.Substring(0, 6) + "..." : k.Key,
+ Owner = k.Owner,
+ ExpiresAt = k.ExpiresAt,
+ IsActive = k.IsActive
+ })
+ .ToListAsync();
+
+ return Ok(keys);
+ }
+}
+
+public class CreateApiKeyRequest
+{
+ public string? Owner { get; set; }
+ public DateTime ExpiresAt { get; set; }
+}
+
+public class ApiKeyResponse
+{
+ public int Id { get; set; }
+ public string Key { get; set; } = string.Empty;
+ public string? Owner { get; set; }
+ public DateTime ExpiresAt { get; set; }
+ public bool IsActive { get; set; }
+}
diff --git a/IMS.API/Controllers/TestController.cs b/IMS.API/Controllers/TestController.cs
new file mode 100644
index 0000000..83f5420
--- /dev/null
+++ b/IMS.API/Controllers/TestController.cs
@@ -0,0 +1,16 @@
+using IMS.API.Attributes;
+using Microsoft.AspNetCore.Mvc;
+
+namespace IMS.API.Controllers;
+
+[ApiController]
+[Route("api/test")]
+public class TestController : ControllerBase
+{
+ [HttpGet("protected")]
+ [ApiKeyAuthorize]
+ public IActionResult Protected()
+ {
+ return Ok(new { Message = "You accessed a protected endpoint with a valid API key." });
+ }
+}
diff --git a/IMS.API/Program.cs b/IMS.API/Program.cs
index 40574e7..4ce1e57 100644
--- a/IMS.API/Program.cs
+++ b/IMS.API/Program.cs
@@ -1,3 +1,4 @@
+using IMS.API.Attributes;
using IMS.API.Services;
using IMS.Core.Contracts;
using IMS.Core.Entities;
diff --git a/IMS.Core/Entities/ApiKey.cs b/IMS.Core/Entities/ApiKey.cs
new file mode 100644
index 0000000..4b7a44a
--- /dev/null
+++ b/IMS.Core/Entities/ApiKey.cs
@@ -0,0 +1,11 @@
+namespace IMS.Core.Entities;
+
+public class ApiKey
+{
+ public int Id { get; set; }
+ public string Key { get; set; } = string.Empty;
+ public string? Owner { get; set; }
+ public DateTime ExpiresAt { get; set; }
+ public bool IsActive { get; set; } = true;
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/IMS.Infrastructure/Data/ApplicationDbContext.cs b/IMS.Infrastructure/Data/ApplicationDbContext.cs
index 58c8bfd..3e61cd9 100644
--- a/IMS.Infrastructure/Data/ApplicationDbContext.cs
+++ b/IMS.Infrastructure/Data/ApplicationDbContext.cs
@@ -14,6 +14,7 @@ public ApplicationDbContext(DbContextOptions options)
public DbSet Transactions => Set();
public DbSet Payments => Set();
public DbSet LowStockAlerts => Set();
+ public DbSet ApiKeys => Set();
protected override void OnModelCreating(ModelBuilder builder)
diff --git a/IMS.Infrastructure/Data/DataSeeder.cs b/IMS.Infrastructure/Data/DataSeeder.cs
index 9df9ff8..74cc4fc 100644
--- a/IMS.Infrastructure/Data/DataSeeder.cs
+++ b/IMS.Infrastructure/Data/DataSeeder.cs
@@ -22,7 +22,25 @@ public static async Task SeedDataAsync(IServiceProvider service)
if (context.Database.GetPendingMigrationsAsync().GetAwaiter().GetResult().Any())
await context.Database.MigrateAsync();
- if (await context.categories.AnyAsync()) return;
+ if (await context.categories.AnyAsync())
+ {
+ if (!await context.ApiKeys.AnyAsync())
+ {
+ var defaultApiKey = new ApiKey
+ {
+ Key = "IMS-Demo-Key-2026",
+ Owner = "Demo/Resume",
+ ExpiresAt = DateTime.UtcNow.AddMonths(1),
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ context.ApiKeys.Add(defaultApiKey);
+ await context.SaveChangesAsync();
+ }
+
+ return;
+ }
var categorypath = Path.Combine(AppContext.BaseDirectory, "Data", "SeedData", "categories.json");
@@ -73,7 +91,20 @@ public static async Task SeedDataAsync(IServiceProvider service)
await context.SaveChangesAsync();
-
+ if (!await context.ApiKeys.AnyAsync())
+ {
+ var defaultApiKey = new ApiKey
+ {
+ Key = "IMS-Demo-Key-2026",
+ Owner = "Demo/Resume",
+ ExpiresAt = DateTime.UtcNow.AddMonths(1),
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ context.ApiKeys.Add(defaultApiKey);
+ await context.SaveChangesAsync();
+ }
}
private class ProductSeedDto
{
diff --git a/IMS.Infrastructure/Migrations/20260812161702_AddApiKey.Designer.cs b/IMS.Infrastructure/Migrations/20260812161702_AddApiKey.Designer.cs
new file mode 100644
index 0000000..7ff8da8
--- /dev/null
+++ b/IMS.Infrastructure/Migrations/20260812161702_AddApiKey.Designer.cs
@@ -0,0 +1,533 @@
+//
+using System;
+using IMS.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace IMS.Infrastructure.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260812161702_AddApiKey")]
+ partial class AddApiKey
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.30")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("IMS.Core.Entities.ApiKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("datetime2");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Owner")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("ApiKeys");
+ });
+
+ modelBuilder.Entity("IMS.Core.Entities.ApplicationUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("int");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("bit");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex")
+ .HasFilter("[NormalizedUserName] IS NOT NULL");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("IMS.Core.Entities.Category", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("categories");
+ });
+
+ modelBuilder.Entity("IMS.Core.Entities.LowStockAlert", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AlertDate")
+ .HasColumnType("datetime2");
+
+ b.Property("IsResolved")
+ .HasColumnType("bit");
+
+ b.Property("ProductId")
+ .HasColumnType("int");
+
+ b.Property("ResolvedDate")
+ .HasColumnType("datetime2");
+
+ b.Property("Threshold")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProductId");
+
+ b.ToTable("LowStockAlerts");
+ });
+
+ modelBuilder.Entity("IMS.Core.Entities.Payment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Amount")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("PaymentDate")
+ .HasColumnType("datetime2");
+
+ b.Property("PaymentMethod")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TransactionId")
+ .HasColumnType("int");
+
+ b.Property("TransactionReference")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TransactionId");
+
+ b.ToTable("Payments");
+ });
+
+ modelBuilder.Entity("IMS.Core.Entities.Product", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CategoryId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("LastUpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("QuantityInStock")
+ .HasColumnType("int");
+
+ b.Property("Supplier")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CategoryId");
+
+ b.ToTable("Products");
+ });
+
+ modelBuilder.Entity("IMS.Core.Entities.Transaction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Date")
+ .HasColumnType("datetime2");
+
+ b.Property("ProductId")
+ .HasColumnType("int");
+
+ b.Property("Quantity")
+ .HasColumnType("int");
+
+ b.Property("TotalAmount")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProductId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Transactions");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex")
+ .HasFilter("[NormalizedName] IS NOT NULL");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderKey")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("RoleId")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Value")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("IMS.Core.Entities.LowStockAlert", b =>
+ {
+ b.HasOne("IMS.Core.Entities.Product", "Product")
+ .WithMany("Alerts")
+ .HasForeignKey("ProductId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Product");
+ });
+
+ modelBuilder.Entity("IMS.Core.Entities.Payment", b =>
+ {
+ b.HasOne("IMS.Core.Entities.Transaction", null)
+ .WithMany()
+ .HasForeignKey("TransactionId")
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ modelBuilder.Entity("IMS.Core.Entities.Product", b =>
+ {
+ b.HasOne("IMS.Core.Entities.Category", "Category")
+ .WithMany("Products")
+ .HasForeignKey("CategoryId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("IMS.Core.Entities.Transaction", b =>
+ {
+ b.HasOne("IMS.Core.Entities.Product", "Product")
+ .WithMany("Transactions")
+ .HasForeignKey("ProductId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("IMS.Core.Entities.ApplicationUser", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.Navigation("Product");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("IMS.Core.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("IMS.Core.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("IMS.Core.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("IMS.Core.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("IMS.Core.Entities.Category", b =>
+ {
+ b.Navigation("Products");
+ });
+
+ modelBuilder.Entity("IMS.Core.Entities.Product", b =>
+ {
+ b.Navigation("Alerts");
+
+ b.Navigation("Transactions");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/IMS.Infrastructure/Migrations/20260812161702_AddApiKey.cs b/IMS.Infrastructure/Migrations/20260812161702_AddApiKey.cs
new file mode 100644
index 0000000..d6965d8
--- /dev/null
+++ b/IMS.Infrastructure/Migrations/20260812161702_AddApiKey.cs
@@ -0,0 +1,39 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IMS.Infrastructure.Migrations
+{
+ ///
+ public partial class AddApiKey : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "ApiKeys",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ Key = table.Column(type: "nvarchar(max)", nullable: false),
+ Owner = table.Column(type: "nvarchar(max)", nullable: true),
+ ExpiresAt = table.Column(type: "datetime2", nullable: false),
+ IsActive = table.Column(type: "bit", nullable: false),
+ CreatedAt = table.Column(type: "datetime2", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ApiKeys", x => x.Id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "ApiKeys");
+ }
+ }
+}
diff --git a/IMS.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/IMS.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs
index 28a730d..c15f7ea 100644
--- a/IMS.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/IMS.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -17,11 +17,40 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "8.0.27")
+ .HasAnnotation("ProductVersion", "8.0.30")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+ modelBuilder.Entity("IMS.Core.Entities.ApiKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("datetime2");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Owner")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("ApiKeys");
+ });
+
modelBuilder.Entity("IMS.Core.Entities.ApplicationUser", b =>
{
b.Property("Id")
From ffc5bc30037d897995e2e75bcdf9408407c41d02 Mon Sep 17 00:00:00 2001
From: Mario Medhat <118922155+Mariomedhat899@users.noreply.github.com>
Date: Wed, 12 Aug 2026 19:59:55 +0300
Subject: [PATCH 5/9] Add Dockerfile for Railway deployment
---
Dockerfile | 15 +++++++++++++++
1 file changed, 15 insertions(+)
create mode 100644 Dockerfile
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..a67a8ef
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+WORKDIR /src
+COPY ["IMS.API/IMS.API.csproj", "IMS.API/"]
+COPY ["IMS.Infrastructure/IMS.Infrastructure.csproj", "IMS.Infrastructure/"]
+COPY ["IMS.Core/IMS.Core.csproj", "IMS.Core/"]
+RUN dotnet restore "IMS.API/IMS.API.csproj"
+COPY . .
+RUN dotnet publish "IMS.API/IMS.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
+WORKDIR /app
+EXPOSE 8080
+ENV ASPNETCORE_URLS=http://+:8080
+COPY --from=build /app/publish .
+ENTRYPOINT ["dotnet", "IMS.API.dll"]
From f4ebf47c8cbcb6f4e6e907951bfb07306af54db4 Mon Sep 17 00:00:00 2001
From: Mario Medhat <118922155+Mariomedhat899@users.noreply.github.com>
Date: Wed, 12 Aug 2026 20:43:13 +0300
Subject: [PATCH 6/9] Use Railway Nixpacks deploy config
---
railway.toml | 7 +++++++
1 file changed, 7 insertions(+)
create mode 100644 railway.toml
diff --git a/railway.toml b/railway.toml
new file mode 100644
index 0000000..234b0a8
--- /dev/null
+++ b/railway.toml
@@ -0,0 +1,7 @@
+[build]
+builder = "nixpacks"
+
+[deploy]
+startCommand = "dotnet IMS.API/bin/Release/net8.0/publish/IMS.API.dll"
+restartPolicyType = "on_failure"
+restartPolicyMaxRetries = 10
From 0ab431429afbabb7436c2cda032b5c0e551c9724 Mon Sep 17 00:00:00 2001
From: Mario Medhat <118922155+Mariomedhat899@users.noreply.github.com>
Date: Wed, 12 Aug 2026 20:45:52 +0300
Subject: [PATCH 7/9] Add start.sh for Railway build
---
start.sh | 3 +++
1 file changed, 3 insertions(+)
create mode 100644 start.sh
diff --git a/start.sh b/start.sh
new file mode 100644
index 0000000..b2b6fca
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+dotnet publish IMS.API/IMS.API.csproj -c Release -o publish
+dotnet publish/IMS.API.dll
From 2a2669099bb05bb994d32c24891923181404533b Mon Sep 17 00:00:00 2001
From: Mario Medhat <118922155+Mariomedhat899@users.noreply.github.com>
Date: Wed, 12 Aug 2026 20:50:30 +0300
Subject: [PATCH 8/9] Clean up deploy configs for Railway
---
Dockerfile | 15 ---------------
1 file changed, 15 deletions(-)
delete mode 100644 Dockerfile
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index a67a8ef..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,15 +0,0 @@
-FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
-WORKDIR /src
-COPY ["IMS.API/IMS.API.csproj", "IMS.API/"]
-COPY ["IMS.Infrastructure/IMS.Infrastructure.csproj", "IMS.Infrastructure/"]
-COPY ["IMS.Core/IMS.Core.csproj", "IMS.Core/"]
-RUN dotnet restore "IMS.API/IMS.API.csproj"
-COPY . .
-RUN dotnet publish "IMS.API/IMS.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
-
-FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
-WORKDIR /app
-EXPOSE 8080
-ENV ASPNETCORE_URLS=http://+:8080
-COPY --from=build /app/publish .
-ENTRYPOINT ["dotnet", "IMS.API.dll"]
From e5a01a7dd75fec8289cb6d2a8400b048e298ffe7 Mon Sep 17 00:00:00 2001
From: Mario Medhat <118922155+Mariomedhat899@users.noreply.github.com>
Date: Wed, 12 Aug 2026 23:38:50 +0300
Subject: [PATCH 9/9] Remove DB password from appsettings.json; move to env var
---
IMS.API/appsettings.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/IMS.API/appsettings.json b/IMS.API/appsettings.json
index e0413ea..605cb5e 100644
--- a/IMS.API/appsettings.json
+++ b/IMS.API/appsettings.json
@@ -8,11 +8,11 @@
"AllowedHosts": "*",
"ConnectionStrings": {
- "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=IMS_DB;Trusted_Connection=True;TrustServerCertificate=True"
+ "DefaultConnection": "Server=db63575.public.databaseasp.net;Database=db63575;User Id=db63575;Password=__MONSTERASP_ENV__;Encrypt=False;MultipleActiveResultSets=True;TrustServerCertificate=True;"
},
- "BaseUrl": "https://localhost:7086",
+ "BaseUrl": "http://imcapp.runasp.net",
"JwtOptions": {
- "Issuer": "https://localhost:7086",
+ "Issuer": "http://imcapp.runasp.net",
"Audience": "InventoryManagementApi",
"SecretKey": "YourSuperSecretKey123456789012345678901234567890",
"ExpiryMinutes": 60