mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 01:47:41 +00:00
1968 lines
78 KiB
HTML
1968 lines
78 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>WIN Student Goal Tracker – Developer Documentation</title>
|
||
<meta name="description" content="Developer documentation for the WIN Student Goal Tracker project." />
|
||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root{
|
||
--bg:#eef3f9;
|
||
--surface:#ffffff;
|
||
--surface-2:#f8fbff;
|
||
--sidebar:#0f172a;
|
||
--sidebar-text:#cbd5e1;
|
||
--text:#1f2937;
|
||
--heading:#0f172a;
|
||
--accent:#2563eb;
|
||
--accent-soft:#dbeafe;
|
||
--border:#dbe3f0;
|
||
--shadow:0 10px 30px rgba(15,23,42,.08);
|
||
--radius:16px;
|
||
}
|
||
|
||
*{box-sizing:border-box}
|
||
html{scroll-behavior:smooth}
|
||
|
||
body{
|
||
margin:0;
|
||
font-family: Inter, Arial, Helvetica, sans-serif;
|
||
background:linear-gradient(180deg,#f8fbff 0%, #eef3f9 100%);
|
||
color:var(--text);
|
||
line-height:1.65;
|
||
}
|
||
|
||
.page{
|
||
display:grid;
|
||
grid-template-columns:280px minmax(0,1fr);
|
||
gap:24px;
|
||
max-width:1320px;
|
||
margin:auto;
|
||
padding:24px;
|
||
align-items:start;
|
||
}
|
||
|
||
.sidebar{
|
||
position:sticky;
|
||
top:20px;
|
||
background:var(--sidebar);
|
||
color:var(--sidebar-text);
|
||
padding:22px 18px;
|
||
border-radius:20px;
|
||
box-shadow:var(--shadow);
|
||
height:fit-content;
|
||
}
|
||
|
||
.sidebar .brand{
|
||
font-size:1rem;
|
||
font-weight:700;
|
||
color:#fff;
|
||
margin-bottom:18px;
|
||
padding-bottom:14px;
|
||
border-bottom:1px solid rgba(255,255,255,.08);
|
||
}
|
||
|
||
.sidebar a{
|
||
display:block;
|
||
padding:10px 12px;
|
||
margin:4px 0;
|
||
border-radius:10px;
|
||
text-decoration:none;
|
||
color:var(--sidebar-text);
|
||
font-size:.95rem;
|
||
transition:all .2s ease;
|
||
}
|
||
|
||
.sidebar a:hover{
|
||
background:rgba(255,255,255,.08);
|
||
color:#fff;
|
||
}
|
||
|
||
.content{
|
||
min-width:0;
|
||
}
|
||
|
||
.hero{
|
||
background:linear-gradient(135deg, rgba(37,99,235,.95), rgba(15,23,42,.95));
|
||
color:white;
|
||
padding:38px 36px;
|
||
border-radius:22px;
|
||
box-shadow:var(--shadow);
|
||
margin-bottom:22px;
|
||
}
|
||
|
||
.hero h1{
|
||
margin:0 0 10px;
|
||
font-size:2.1rem;
|
||
line-height:1.15;
|
||
letter-spacing:-0.02em;
|
||
color:#fff;
|
||
}
|
||
|
||
.hero p{
|
||
margin:4px 0;
|
||
color:rgba(255,255,255,.9);
|
||
}
|
||
|
||
.meta{
|
||
display:inline-block;
|
||
background:rgba(255,255,255,.14);
|
||
color:#fff;
|
||
border-radius:999px;
|
||
padding:6px 12px;
|
||
font-size:.88rem;
|
||
font-weight:600;
|
||
margin-bottom:12px;
|
||
}
|
||
|
||
section{
|
||
background:var(--surface);
|
||
padding:28px;
|
||
border-radius:20px;
|
||
margin-bottom:20px;
|
||
border:1px solid var(--border);
|
||
box-shadow:var(--shadow);
|
||
scroll-margin-top:20px;
|
||
}
|
||
|
||
h2{
|
||
margin:0 0 14px;
|
||
color:var(--heading);
|
||
font-size:1.45rem;
|
||
letter-spacing:-0.01em;
|
||
padding-bottom:10px;
|
||
border-bottom:2px solid var(--accent-soft);
|
||
}
|
||
|
||
h3{
|
||
margin:22px 0 10px;
|
||
color:var(--heading);
|
||
font-size:1.05rem;
|
||
}
|
||
|
||
p{
|
||
margin:10px 0 0;
|
||
}
|
||
|
||
ul, ol{
|
||
padding-left:22px;
|
||
margin:10px 0 0;
|
||
}
|
||
|
||
li{
|
||
margin:8px 0;
|
||
}
|
||
|
||
.stack{
|
||
display:grid;
|
||
grid-template-columns:repeat(auto-fit,minmax(220px,1fr));
|
||
gap:14px;
|
||
margin-top:14px;
|
||
}
|
||
|
||
.card{
|
||
border:1px solid var(--border);
|
||
background:var(--surface-2);
|
||
padding:16px;
|
||
border-radius:14px;
|
||
box-shadow:0 4px 14px rgba(15,23,42,.04);
|
||
}
|
||
|
||
.card strong{
|
||
display:block;
|
||
margin-bottom:6px;
|
||
color:var(--heading);
|
||
}
|
||
|
||
.flow{
|
||
background:linear-gradient(90deg,#eff6ff,#f8fbff);
|
||
border:1px solid #bfdbfe;
|
||
border-left:5px solid var(--accent);
|
||
color:#1e3a8a;
|
||
padding:14px 16px;
|
||
border-radius:12px;
|
||
margin:10px 0 14px;
|
||
font-family:"Courier New", Courier, monospace;
|
||
font-size:.95rem;
|
||
overflow:auto;
|
||
}
|
||
|
||
pre{
|
||
background:#0b1220;
|
||
color:#e5e7eb;
|
||
padding:16px 18px;
|
||
border-radius:14px;
|
||
overflow:auto;
|
||
border:1px solid #1e293b;
|
||
box-shadow:inset 0 1px 0 rgba(255,255,255,.03);
|
||
margin-top:12px;
|
||
}
|
||
|
||
code{
|
||
font-family:"Courier New", Courier, monospace;
|
||
}
|
||
|
||
.pill{
|
||
display:inline-block;
|
||
background:var(--accent-soft);
|
||
color:var(--accent);
|
||
border-radius:999px;
|
||
padding:6px 12px;
|
||
font-size:.88rem;
|
||
font-weight:600;
|
||
margin:10px 8px 0 0;
|
||
}
|
||
|
||
@media (max-width: 980px){
|
||
.page{
|
||
grid-template-columns:1fr;
|
||
}
|
||
|
||
.sidebar{
|
||
position:relative;
|
||
top:0;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px){
|
||
.page{
|
||
padding:14px;
|
||
gap:16px;
|
||
}
|
||
|
||
.hero{
|
||
padding:28px 22px;
|
||
}
|
||
|
||
.hero h1{
|
||
font-size:1.7rem;
|
||
}
|
||
|
||
section{
|
||
padding:20px;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
/* ── Appendix C – Architecture Overview (all scoped to #appendix-c) ── */
|
||
.arch-link {
|
||
display: inline-flex; align-items: center; gap: .4rem; font-size: .88rem;
|
||
color: var(--accent); text-decoration: none; font-weight: 600;
|
||
margin-bottom: 1.2rem;
|
||
}
|
||
.arch-link:hover { text-decoration: underline; }
|
||
#appendix-c .asec-title {
|
||
font-size: 1rem; font-weight: 700; color: var(--heading);
|
||
margin: 1.8rem 0 .8rem; padding-bottom: .35rem; border-bottom: 2px solid var(--border);
|
||
display: flex; align-items: center; gap: .4rem;
|
||
}
|
||
/* tier diagram */
|
||
#appendix-c .arch-diagram { display: flex; flex-direction: column; gap: 0; align-items: stretch; }
|
||
#appendix-c .tier { display: flex; align-items: stretch; gap: .75rem; position: relative; }
|
||
#appendix-c .tier-label {
|
||
writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);
|
||
font-size: .65rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
|
||
color: white; padding: .5rem .35rem; border-radius: 8px 0 0 8px;
|
||
display: flex; align-items: center; justify-content: center; min-width: 28px;
|
||
}
|
||
#appendix-c .tier-body { flex: 1; display: flex; gap: .75rem; flex-wrap: wrap; padding: .9rem; border-radius: 0 12px 12px 0; }
|
||
#appendix-c .tier--client .tier-label { background: #2563eb; }
|
||
#appendix-c .tier--client .tier-body { background: #eff6ff; border: 1.5px solid #bfdbfe; }
|
||
#appendix-c .tier--proxy .tier-label { background: #64748b; }
|
||
#appendix-c .tier--proxy .tier-body { background: #f8fafc; border: 1.5px solid #cbd5e1; }
|
||
#appendix-c .tier--api .tier-label { background: #16a34a; }
|
||
#appendix-c .tier--api .tier-body { background: #f0fdf4; border: 1.5px solid #bbf7d0; }
|
||
#appendix-c .tier--data .tier-label { background: #d97706; }
|
||
#appendix-c .tier--data .tier-body { background: #fffbeb; border: 1.5px solid #fde68a; }
|
||
#appendix-c .tier--ext .tier-label { background: #7c3aed; }
|
||
#appendix-c .tier--ext .tier-body { background: #faf5ff; border: 1.5px solid #ddd6fe; }
|
||
#appendix-c .tier-arrow {
|
||
display: flex; align-items: center; justify-content: center;
|
||
height: 28px; gap: .4rem; color: #6b7280; font-size: .7rem; margin-left: 28px;
|
||
}
|
||
#appendix-c .tier-arrow svg { flex-shrink: 0; }
|
||
/* boxes */
|
||
#appendix-c .box {
|
||
background: white; border-radius: 10px; padding: .55rem .8rem;
|
||
box-shadow: 0 2px 6px rgba(0,0,0,.06); border: 1px solid rgba(0,0,0,.06); min-width: 130px;
|
||
}
|
||
#appendix-c .box-title { font-size: .78rem; font-weight: 700; margin: 0 0 .25rem; color: var(--heading); }
|
||
#appendix-c .box ul { list-style: none; font-size: .7rem; color: #4b5563; padding: 0; margin: 0; }
|
||
#appendix-c .box ul li { margin-bottom: .12rem; }
|
||
#appendix-c .box ul li::before { content: "• "; color: #9ca3af; }
|
||
#appendix-c .box--blue { border-top: 3px solid #2563eb; }
|
||
#appendix-c .box--green { border-top: 3px solid #16a34a; }
|
||
#appendix-c .box--amber { border-top: 3px solid #d97706; }
|
||
#appendix-c .box--purple { border-top: 3px solid #7c3aed; }
|
||
#appendix-c .box--slate { border-top: 3px solid #64748b; }
|
||
/* badges */
|
||
#appendix-c .badge {
|
||
display: inline-block; font-size: .6rem; font-weight: 700;
|
||
padding: .1rem .35rem; border-radius: 4px; margin-left: .2rem; vertical-align: middle;
|
||
}
|
||
#appendix-c .badge--blue { background: #dbeafe; color: #1d4ed8; }
|
||
#appendix-c .badge--green { background: #dcfce7; color: #15803d; }
|
||
#appendix-c .badge--amber { background: #fef3c7; color: #b45309; }
|
||
#appendix-c .badge--purple { background: #ede9fe; color: #6d28d9; }
|
||
/* auth flow — renamed .aflow* to avoid conflict with technical.html's .flow rule */
|
||
#appendix-c .aflow { display: flex; align-items: flex-start; gap: 0; overflow-x: auto; padding-bottom: .5rem; }
|
||
#appendix-c .aflow-step { display: flex; flex-direction: column; align-items: center; min-width: 150px; max-width: 165px; }
|
||
#appendix-c .aflow-bubble {
|
||
background: white; border-radius: 10px; padding: .55rem .7rem;
|
||
box-shadow: 0 2px 6px rgba(0,0,0,.07); border: 1.5px solid var(--border);
|
||
font-size: .7rem; text-align: center; width: 100%;
|
||
}
|
||
#appendix-c .aflow-bubble strong { display: block; font-size: .78rem; margin: 0 0 .2rem; color: var(--heading); font-weight: 700; }
|
||
#appendix-c .aflow-bubble .note { color: #6b7280; margin-top: .2rem; font-style: italic; }
|
||
#appendix-c .aflow-num {
|
||
width: 22px; height: 22px; border-radius: 50%; background: var(--heading); color: white;
|
||
font-size: .7rem; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-bottom: .35rem;
|
||
}
|
||
#appendix-c .aflow-connector { display: flex; align-items: center; padding-top: 11px; flex-shrink: 0; }
|
||
#appendix-c .aflow-connector svg { color: #9ca3af; }
|
||
/* data model */
|
||
#appendix-c .entity-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: .7rem; }
|
||
#appendix-c .entity { background: white; border-radius: 10px; box-shadow: 0 2px 6px rgba(0,0,0,.06); border: 1px solid var(--border); overflow: hidden; }
|
||
#appendix-c .entity-header { padding: .4rem .7rem; font-size: .75rem; font-weight: 700; color: white; }
|
||
#appendix-c .entity-fields { padding: .45rem .7rem; font-size: .68rem; color: #4b5563; }
|
||
#appendix-c .entity-fields div { padding: .1rem 0; border-bottom: 1px solid #f3f4f6; }
|
||
#appendix-c .entity-fields div:last-child { border-bottom: none; }
|
||
#appendix-c .pk { color: #d97706; font-weight: 700; }
|
||
#appendix-c .fk { color: var(--accent); }
|
||
#appendix-c .field-note { color: #9ca3af; font-size: .63rem; margin-left: .2rem; }
|
||
#appendix-c .rel-summary {
|
||
margin-top: .8rem; background: white; border-radius: 10px;
|
||
padding: .8rem 1rem; border: 1px solid var(--border); font-size: .73rem; color: var(--text);
|
||
}
|
||
/* role pyramid + permission matrix */
|
||
#appendix-c .role-and-matrix { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||
@media (max-width: 720px) { #appendix-c .role-and-matrix { grid-template-columns: 1fr; } }
|
||
#appendix-c .role-pane { background: white; border-radius: 12px; padding: 1rem; box-shadow: 0 2px 6px rgba(0,0,0,.06); border: 1px solid var(--border); }
|
||
#appendix-c .pane-label { font-size: .8rem; font-weight: 700; margin: 0 0 .8rem; color: var(--heading); }
|
||
#appendix-c .role-pyramid { display: flex; flex-direction: column; align-items: center; gap: .35rem; padding: .25rem 0; }
|
||
#appendix-c .role-row { display: flex; justify-content: center; }
|
||
#appendix-c .role-chip { border-radius: 8px; padding: .45rem 1rem; font-size: .75rem; font-weight: 700; color: white; text-align: center; min-width: 140px; }
|
||
#appendix-c table.matrix { width: 100%; border-collapse: collapse; font-size: .69rem; }
|
||
#appendix-c table.matrix th, #appendix-c table.matrix td { border: 1px solid var(--border); padding: .3rem .45rem; text-align: center; }
|
||
#appendix-c table.matrix th { background: var(--surface-2); font-weight: 700; color: var(--text); }
|
||
#appendix-c table.matrix td:first-child { text-align: left; font-weight: 600; color: var(--text); }
|
||
#appendix-c .allow { background: #dcfce7; color: #15803d; font-weight: 700; border-radius: 4px; padding: .08rem .28rem; font-size: .63rem; display: inline-block; }
|
||
#appendix-c .mine { background: #fef3c7; color: #b45309; font-weight: 700; border-radius: 4px; padding: .08rem .28rem; font-size: .63rem; display: inline-block; }
|
||
#appendix-c .deny { background: #fee2e2; color: #b91c1c; font-weight: 700; border-radius: 4px; padding: .08rem .28rem; font-size: .63rem; display: inline-block; }
|
||
/* info cards */
|
||
#appendix-c .info-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: .7rem; }
|
||
#appendix-c .info-card { background: white; border-radius: 10px; padding: .9rem 1rem; box-shadow: 0 2px 6px rgba(0,0,0,.06); border: 1px solid var(--border); }
|
||
#appendix-c .info-card h3 { font-size: .82rem; font-weight: 700; margin: 0 0 .45rem; color: var(--heading); border: none; padding: 0; }
|
||
#appendix-c .info-card ul { list-style: none; font-size: .72rem; color: #4b5563; padding: 0; margin: 0; }
|
||
#appendix-c .info-card ul li { padding: .18rem 0; border-bottom: 1px solid #f3f4f6; }
|
||
#appendix-c .info-card ul li:last-child { border-bottom: none; }
|
||
#appendix-c .info-card ul li::before { content: "→ "; color: #9ca3af; }
|
||
</style>
|
||
|
||
<style>
|
||
/* ─── ERD Embed ─── */
|
||
#erd-embed {
|
||
--erd-bg: #0f1117;
|
||
--erd-surface: #181a24;
|
||
--erd-surface-hover: #1e2130;
|
||
--erd-border: #2a2d3e;
|
||
--erd-border-highlight: #3d4160;
|
||
--erd-text: #c9cdd8;
|
||
--erd-text-muted: #6b7084;
|
||
--erd-text-bright: #eef0f6;
|
||
--erd-pk: #f0b866;
|
||
--erd-pk-bg: rgba(240,184,102,0.08);
|
||
--erd-fk: #6ea8fe;
|
||
--erd-fk-bg: rgba(110,168,254,0.08);
|
||
--erd-col: #8b91a8;
|
||
--erd-type: #5a5f75;
|
||
--erd-line-color: #3d5a80;
|
||
--erd-line-highlight: #6ea8fe;
|
||
--erd-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||
--erd-shadow-lg: 0 8px 40px rgba(0,0,0,0.6);
|
||
--erd-radius: 10px;
|
||
position: relative;
|
||
height: 720px;
|
||
overflow: hidden;
|
||
background: var(--erd-bg);
|
||
color: var(--erd-text);
|
||
font-family: 'DM Sans', sans-serif;
|
||
border-radius: 12px;
|
||
margin-top: 16px;
|
||
}
|
||
#erd-embed #toolbar {
|
||
position: absolute;
|
||
top: 0; left: 0; right: 0;
|
||
height: 52px;
|
||
background: var(--erd-surface);
|
||
border-bottom: 1px solid var(--erd-border);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 20px;
|
||
z-index: 10;
|
||
gap: 16px;
|
||
border-radius: 0;
|
||
}
|
||
#erd-embed #toolbar h1 {
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--erd-text-bright);
|
||
letter-spacing: -0.02em;
|
||
margin: 0;
|
||
padding: 0;
|
||
border: none;
|
||
}
|
||
#erd-embed #toolbar .separator { width: 1px; height: 24px; background: var(--erd-border); }
|
||
#erd-embed #toolbar .stat { font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--erd-text-muted); margin: 0; }
|
||
#erd-embed #toolbar .stat span { color: var(--erd-text); font-weight: 600; }
|
||
#erd-embed .toolbar-actions { margin-left: auto; display: flex; gap: 8px; }
|
||
#erd-embed .toolbar-btn {
|
||
background: var(--erd-border);
|
||
border: none;
|
||
color: var(--erd-text);
|
||
font-family: 'DM Sans', sans-serif;
|
||
font-size: 12px;
|
||
padding: 6px 14px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
#erd-embed .toolbar-btn:hover { background: var(--erd-border-highlight); color: var(--erd-text-bright); }
|
||
#erd-embed #canvas-container {
|
||
position: absolute;
|
||
top: 52px; left: 0; right: 0; bottom: 0;
|
||
overflow: hidden;
|
||
cursor: grab;
|
||
}
|
||
#erd-embed #canvas-container:active { cursor: grabbing; }
|
||
#erd-embed #canvas { position: absolute; transform-origin: 0 0; }
|
||
#erd-embed svg#lines { position: absolute; top: 0; left: 0; pointer-events: none; overflow: visible; }
|
||
#erd-embed .table-node {
|
||
position: absolute;
|
||
background: var(--erd-surface);
|
||
border: 1px solid var(--erd-border);
|
||
border-radius: var(--erd-radius);
|
||
min-width: 240px;
|
||
box-shadow: var(--erd-shadow);
|
||
cursor: move;
|
||
user-select: none;
|
||
transition: box-shadow 0.2s, border-color 0.2s;
|
||
}
|
||
#erd-embed .table-node:hover { border-color: var(--erd-border-highlight); box-shadow: var(--erd-shadow-lg); z-index: 10; }
|
||
#erd-embed .table-node.dragging { z-index: 100; border-color: var(--erd-fk); box-shadow: 0 0 0 1px var(--erd-fk), var(--erd-shadow-lg); }
|
||
#erd-embed .table-header { padding: 10px 14px; border-bottom: 1px solid var(--erd-border); display: flex; align-items: center; gap: 8px; }
|
||
#erd-embed .table-icon { width: 20px; height: 20px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; flex-shrink: 0; }
|
||
#erd-embed .table-header .table-name { font-family: 'IBM Plex Mono', monospace; font-size: 13px; font-weight: 600; color: var(--erd-text-bright); letter-spacing: -0.01em; }
|
||
#erd-embed .table-body { padding: 4px 0; }
|
||
#erd-embed .column-row { display: flex; align-items: center; padding: 3px 14px; gap: 8px; font-family: 'IBM Plex Mono', monospace; font-size: 11.5px; line-height: 1.6; transition: background 0.1s; margin: 0; }
|
||
#erd-embed .column-row:hover { background: var(--erd-surface-hover); }
|
||
#erd-embed .col-badge { font-size: 8px; font-weight: 700; padding: 1px 4px; border-radius: 3px; letter-spacing: 0.05em; min-width: 20px; text-align: center; flex-shrink: 0; }
|
||
#erd-embed .col-badge.pk { background: var(--erd-pk-bg); color: var(--erd-pk); }
|
||
#erd-embed .col-badge.fk { background: var(--erd-fk-bg); color: var(--erd-fk); }
|
||
#erd-embed .col-badge.empty { min-width: 20px; }
|
||
#erd-embed .col-name { color: var(--erd-col); flex: 1; }
|
||
#erd-embed .col-name.pk-name { color: var(--erd-pk); }
|
||
#erd-embed .col-name.fk-name { color: var(--erd-fk); }
|
||
#erd-embed .col-type { color: var(--erd-type); font-size: 10px; text-align: right; }
|
||
#erd-embed #legend {
|
||
position: absolute;
|
||
bottom: 16px; left: 16px;
|
||
background: var(--erd-surface);
|
||
border: 1px solid var(--erd-border);
|
||
border-radius: var(--erd-radius);
|
||
padding: 12px 16px;
|
||
z-index: 10;
|
||
font-size: 11px;
|
||
display: flex;
|
||
gap: 16px;
|
||
}
|
||
#erd-embed .legend-item { display: flex; align-items: center; gap: 6px; color: var(--erd-text-muted); margin: 0; }
|
||
#erd-embed .legend-dot { width: 8px; height: 8px; border-radius: 2px; }
|
||
#erd-embed #zoom-controls { position: absolute; bottom: 16px; right: 16px; display: flex; gap: 4px; z-index: 10; }
|
||
#erd-embed #zoom-controls button {
|
||
width: 36px; height: 36px;
|
||
background: var(--erd-surface);
|
||
border: 1px solid var(--erd-border);
|
||
border-radius: 8px;
|
||
color: var(--erd-text);
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.15s;
|
||
}
|
||
#erd-embed #zoom-controls button:hover { background: var(--erd-surface-hover); border-color: var(--erd-border-highlight); }
|
||
#erd-embed #minimap {
|
||
position: absolute;
|
||
bottom: 60px; right: 16px;
|
||
width: 180px; height: 120px;
|
||
background: var(--erd-surface);
|
||
border: 1px solid var(--erd-border);
|
||
border-radius: 8px;
|
||
z-index: 10;
|
||
overflow: hidden;
|
||
}
|
||
#erd-embed #minimap canvas { width: 100%; height: 100%; }
|
||
#erd-embed .rel-line { fill: none; stroke: var(--erd-line-color); stroke-width: 1.5; opacity: 0.5; transition: opacity 0.2s, stroke 0.2s; }
|
||
#erd-embed .rel-line.highlighted { stroke: var(--erd-line-highlight); opacity: 1; stroke-width: 2; }
|
||
#erd-embed .rel-marker { fill: var(--erd-line-color); transition: fill 0.2s; }
|
||
#erd-embed .rel-marker.highlighted { fill: var(--erd-line-highlight); }
|
||
#erd-embed .rel-label { font-family: 'IBM Plex Mono', monospace; font-size: 9px; fill: var(--erd-text-muted); pointer-events: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<aside class="sidebar">
|
||
<div class="brand">WIN Developer Docs</div>
|
||
<a href="#desc">1. Project Description</a>
|
||
<a href="#arch">2. Architecture</a>
|
||
<a href="#flow">3. Data Flow</a>
|
||
<a href="#hosting">4. Recommended Hosting</a>
|
||
<a href="#install">5. Application Installation</a>
|
||
<a href="#auth">6. Authentication & Authorization</a>
|
||
<a href="#backup">7. Database Backup</a>
|
||
<a href="#env">8. Environment Variables</a>
|
||
<a href="#partner">9. Partner Statement</a>
|
||
<a href="#walk">10. Installation Walkthrough</a>
|
||
<a href="#analysis">11. Performance & UX Analysis</a>
|
||
<a href="#improve">12. Known Liabilities & Improvements</a>
|
||
<a href="#sustain">13. Sustainability Considerations</a>
|
||
<a href="#appendix-a">Appendix A – ERD</a>
|
||
<a href="#appendix-b">Appendix B – Repository Structure</a>
|
||
<a href="#appendix-c">Appendix C – Architecture Overview</a>
|
||
</aside>
|
||
|
||
<main class="content">
|
||
<div class="hero">
|
||
<div class="meta">Developer Documentation</div>
|
||
<h1>WIN Student Goal Tracker</h1>
|
||
<p>April 2026</p>
|
||
</div>
|
||
|
||
<section id="desc">
|
||
<h2>1. Project Description</h2>
|
||
<p>
|
||
The WIN Student Goal Tracker is a web-based case management system designed to support
|
||
organizations working with adults with special needs. The platform enables staff to track
|
||
student goals, document services, and log critical incidents while maintaining strong privacy,
|
||
auditability, and compliance with FERPA, IDEA, and FAPE.
|
||
</p>
|
||
<p>
|
||
The system is designed with sustainability in mind, ensuring that future developers can
|
||
easily maintain, extend, and redeploy the application.
|
||
</p>
|
||
</section>
|
||
|
||
<section id="arch">
|
||
<h2>2. Architecture</h2>
|
||
|
||
<h3>Technology Stack & Infrastructure</h3>
|
||
<div class="stack">
|
||
<div class="card">
|
||
<strong>Frontend</strong>
|
||
Angular 20
|
||
</div>
|
||
<div class="card">
|
||
<strong>Backend</strong>
|
||
.NET 9.0 (C#) with Dapper ORM
|
||
</div>
|
||
<div class="card">
|
||
<strong>Authentication</strong>
|
||
JWT + Refresh Tokens
|
||
</div>
|
||
<div class="card">
|
||
<strong>Database</strong>
|
||
MySQL
|
||
</div>
|
||
<div class="card">
|
||
<strong>Infrastructure</strong>
|
||
Docker + VPS + Traefik
|
||
</div>
|
||
</div>
|
||
|
||
<h3>Architecture Description</h3>
|
||
<ol>
|
||
<li>Presentation Layer (Angular)</li>
|
||
<li>Application Layer (.NET API)</li>
|
||
<li>Data Layer (MySQL)</li>
|
||
</ol>
|
||
<p>Traefik manages routing and HTTPS traffic.</p>
|
||
</section>
|
||
|
||
<section id="flow">
|
||
<h2>3. Data Flow</h2>
|
||
|
||
<h3>Create Goal</h3>
|
||
<div class="flow">User → Angular → API → Validation → Database → Response → UI</div>
|
||
|
||
<h3>View Dashboard</h3>
|
||
<div class="flow">User → Angular → API → Database → Response → UI</div>
|
||
</section>
|
||
|
||
<section id="hosting">
|
||
<h2>4. Recommended Hosting</h2>
|
||
<ul>
|
||
<li>Frontend: GitHub Pages, Netlify</li>
|
||
<li>Backend: Render, Railway</li>
|
||
<li>Database: PlanetScale, Railway MySQL</li>
|
||
<li>Alternative: VPS with Docker</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section id="install">
|
||
<h2>5. Application Installation</h2>
|
||
|
||
<h3>Prerequisites</h3>
|
||
<p>Node.js, .NET 9 SDK, MySQL, Docker</p>
|
||
|
||
<h3>Frontend</h3>
|
||
<pre><code>cd frontend
|
||
npm install
|
||
npm start</code></pre>
|
||
|
||
<h3>Backend</h3>
|
||
<pre><code>cd backend
|
||
dotnet restore
|
||
dotnet run</code></pre>
|
||
|
||
<h3>Database</h3>
|
||
<p>Create DB, import schema, update <code>.env</code>.</p>
|
||
|
||
<h3>Docker</h3>
|
||
<pre><code>docker-compose up --build</code></pre>
|
||
</section>
|
||
|
||
<section id="auth">
|
||
<h2>6. Authentication & Authorization</h2>
|
||
<p>JWT-based authentication with refresh tokens.</p>
|
||
<p><strong>Key files:</strong> AuthController.cs, JwtService.cs, middleware</p>
|
||
</section>
|
||
|
||
<section id="backup">
|
||
<h2>7. Database Backup</h2>
|
||
|
||
<h3>Backup</h3>
|
||
<pre><code>mysqldump -u username -p dbname > backup.sql</code></pre>
|
||
|
||
<h3>Restore</h3>
|
||
<pre><code>mysql -u username -p dbname < backup.sql</code></pre>
|
||
</section>
|
||
|
||
<section id="env">
|
||
<h2>8. Environment Variables</h2>
|
||
<pre><code>DB_CONNECTION=...
|
||
JWT_SECRET=...
|
||
JWT_EXPIRATION=3600</code></pre>
|
||
<p>Ensure <code>.env</code> is in <code>.gitignore</code>.</p>
|
||
</section>
|
||
|
||
<section id="partner">
|
||
<h2>9. Partner Statement</h2>
|
||
<p>The partner has reviewed the developer documentation and understands the system.</p>
|
||
<p><strong>Date:</strong> April 2026</p>
|
||
</section>
|
||
|
||
<section id="walk">
|
||
<h2>10. Installation Walkthrough Statement</h2>
|
||
<p>A walkthrough was conducted with the partner, who successfully followed the documentation.</p>
|
||
<p><strong>Date:</strong> April 2026</p>
|
||
</section>
|
||
|
||
<section id="analysis">
|
||
<h2>11. Performance & UX Analysis</h2>
|
||
|
||
<h3>Lighthouse Results</h3>
|
||
<ul>
|
||
<li>Mobile Performance: 100</li>
|
||
<li>Desktop Performance: 100</li>
|
||
<li>Accessibility: 100</li>
|
||
<li>Best Practices: 100</li>
|
||
<li>SEO: 100</li>
|
||
</ul>
|
||
|
||
<h3>Form Factor Analysis</h3>
|
||
<ul>
|
||
<li>Mobile Portrait: Fully responsive and functional</li>
|
||
<li>Mobile Landscape: Improved readability and layout</li>
|
||
<li>Desktop: Optimal user experience</li>
|
||
</ul>
|
||
|
||
<h3>UX Observations</h3>
|
||
<p>Clean navigation with consistent user workflows across devices.</p>
|
||
|
||
<span class="pill">Responsive</span>
|
||
<span class="pill">Accessible</span>
|
||
<span class="pill">High Performance</span>
|
||
<span class="pill">SEO Ready</span>
|
||
</section>
|
||
|
||
<section id="improve">
|
||
<h2>12. Known Liabilities & Improvements</h2>
|
||
<p><strong>Issues:</strong> Potential performance scaling and mobile optimization opportunities</p>
|
||
<p><strong>Improvements:</strong> Lazy loading, bundle optimization, responsive enhancements</p>
|
||
</section>
|
||
|
||
<section id="sustain">
|
||
<h2>13. Sustainability Considerations</h2>
|
||
<p>Docker deployment, free-tier hosting, and modular design support long-term maintainability.</p>
|
||
</section>
|
||
|
||
<section id="appendix-a">
|
||
<h2>Appendix A – ERD</h2>
|
||
<p>
|
||
The system includes a structured relational database supporting users, roles, permissions,
|
||
programs, students, goals, progress tracking, and incident logging. The interactive diagram
|
||
below shows all 17 tables and their relationships. Drag tables to rearrange, scroll to zoom,
|
||
and hover a table to highlight its connections.
|
||
</p>
|
||
|
||
<a href="erd.html" class="arch-link">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||
Open full-page ERD
|
||
</a>
|
||
|
||
<div id="erd-embed">
|
||
<div id="toolbar">
|
||
<h1>⬡ ERD</h1>
|
||
<div class="separator"></div>
|
||
<div class="stat">Tables <span id="table-count">17</span></div>
|
||
<div class="separator"></div>
|
||
<div class="stat">Relations <span id="rel-count">0</span></div>
|
||
<div class="toolbar-actions">
|
||
<button class="toolbar-btn" onclick="resetView()">Reset View</button>
|
||
<button class="toolbar-btn" onclick="toggleLabels()">Toggle Labels</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="canvas-container">
|
||
<div id="canvas">
|
||
<svg id="lines" width="6000" height="4000"></svg>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="legend">
|
||
<div class="legend-item"><div class="legend-dot" style="background:#f0b866"></div> Primary Key</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:#6ea8fe"></div> Foreign Key</div>
|
||
<div class="legend-item">
|
||
<svg width="30" height="10"><line x1="0" y1="5" x2="30" y2="5" stroke="#3d5a80" stroke-width="1.5"/></svg>
|
||
Relationship
|
||
</div>
|
||
</div>
|
||
|
||
<div id="zoom-controls">
|
||
<button onclick="zoomIn()">+</button>
|
||
<button onclick="zoomOut()">−</button>
|
||
<button onclick="resetView()" style="font-size:12px">⌂</button>
|
||
</div>
|
||
|
||
<div id="minimap"><canvas id="minimap-canvas"></canvas></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="appendix-b">
|
||
<h2>Appendix B – Repository Structure</h2>
|
||
<pre><code>WinStudentGoalTracker/
|
||
├── api/ .NET 9 backend — controllers, services, repositories, Dockerfile
|
||
├── db/ MySQL schema and stored procedures; docker-init scripts for container startup
|
||
├── docs/ HTML documentation (technical spec, user manual, ERD, architecture, API)
|
||
├── ui/ Angular 20 frontend SPA
|
||
├── prototype/ Early role-assignment prototypes (not production code)
|
||
├── docker-compose.yml Orchestrates API, Angular/Nginx, MySQL, and Traefik containers
|
||
└── WinStudentGoalTracker.sln Visual Studio solution file</code></pre>
|
||
</section>
|
||
|
||
<section id="appendix-c">
|
||
<h2>Appendix C – Architecture Overview</h2>
|
||
<p>High-level diagrams of the system tiers, two-phase auth flow, data model, role hierarchy, deployment, and key design decisions.</p>
|
||
|
||
<a href="architecture.html" class="arch-link">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||
Open full-page Architecture Overview
|
||
</a>
|
||
|
||
<!-- ── 1. System Architecture ── -->
|
||
<div class="asec-title"><span>🏗</span> System Architecture</div>
|
||
|
||
<div class="arch-diagram">
|
||
|
||
<div class="tier tier--client">
|
||
<div class="tier-label">Client</div>
|
||
<div class="tier-body">
|
||
<div class="box box--blue">
|
||
<div class="box-title">Angular SPA <span class="badge badge--blue">v20</span></div>
|
||
<ul>
|
||
<li>TypeScript 5.8 + SCSS</li>
|
||
<li>Angular Signals (state)</li>
|
||
<li>Lazy-loaded routes</li>
|
||
<li>JWT auth interceptor</li>
|
||
<li>Proactive token refresh</li>
|
||
</ul>
|
||
</div>
|
||
<div class="box box--blue">
|
||
<div class="box-title">Desktop Layout</div>
|
||
<ul>
|
||
<li>Home / Workspace</li>
|
||
<li>Student detail view</li>
|
||
<li>Goal & Benchmark modals</li>
|
||
<li>Progress event modals</li>
|
||
<li>Report generation UI</li>
|
||
<li>Admin panel</li>
|
||
</ul>
|
||
</div>
|
||
<div class="box box--blue">
|
||
<div class="box-title">Mobile Layout</div>
|
||
<ul>
|
||
<li>Student card list</li>
|
||
<li>Add progress event</li>
|
||
<li>Toggle benchmark</li>
|
||
</ul>
|
||
</div>
|
||
<div class="box box--blue">
|
||
<div class="box-title">Shared Services</div>
|
||
<ul>
|
||
<li>AuthService (signals)</li>
|
||
<li>ApiService (HTTP)</li>
|
||
<li>StudentService</li>
|
||
<li>AdminService</li>
|
||
<li>ReportPromptService</li>
|
||
<li>PlatformService</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tier-arrow">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
|
||
<span>HTTPS / REST + JWT Bearer — win.opelly.me → winapi.opelly.me</span>
|
||
</div>
|
||
|
||
<div class="tier tier--proxy">
|
||
<div class="tier-label">Proxy</div>
|
||
<div class="tier-body">
|
||
<div class="box box--slate">
|
||
<div class="box-title">Traefik Reverse Proxy</div>
|
||
<ul>
|
||
<li>win.opelly.me → Angular (Nginx :8006)</li>
|
||
<li>winapi.opelly.me → API (:8005)</li>
|
||
<li>Let's Encrypt TLS</li>
|
||
<li>Gzip compression</li>
|
||
<li>Security headers</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tier-arrow">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
|
||
<span>HTTP (internal Docker network)</span>
|
||
</div>
|
||
|
||
<div class="tier tier--api">
|
||
<div class="tier-label">API</div>
|
||
<div class="tier-body">
|
||
<div class="box box--green">
|
||
<div class="box-title">ASP.NET Core 9 <span class="badge badge--green">C#</span></div>
|
||
<ul>
|
||
<li>JWT authentication middleware</li>
|
||
<li>Swagger / OpenAPI</li>
|
||
<li>CORS policy</li>
|
||
<li>Dependency injection</li>
|
||
</ul>
|
||
</div>
|
||
<div class="box box--green">
|
||
<div class="box-title">Controllers</div>
|
||
<ul>
|
||
<li>/api/Auth — login & tokens</li>
|
||
<li>/api/Student — CRUD</li>
|
||
<li>/api/Admin — district/users</li>
|
||
<li>/api/ReportPrompt — prompts</li>
|
||
</ul>
|
||
</div>
|
||
<div class="box box--green">
|
||
<div class="box-title">Services</div>
|
||
<ul>
|
||
<li>TokenService (JWT)</li>
|
||
<li>PermissionService</li>
|
||
<li>PasswordHasher (PBKDF2)</li>
|
||
<li>RecommendationService</li>
|
||
<li>TranscriptionService</li>
|
||
<li>ProgressReportBuilder</li>
|
||
</ul>
|
||
</div>
|
||
<div class="box box--green">
|
||
<div class="box-title">Repositories <span class="badge badge--green">Dapper</span></div>
|
||
<ul>
|
||
<li>StudentRepository</li>
|
||
<li>UserRepository</li>
|
||
<li>AuthRepository</li>
|
||
<li>AdminRepository</li>
|
||
<li>ReportPromptRepository</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tier-arrow">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
|
||
<span>Stored procedures via Dapper — TCP :3309 (Docker internal)</span>
|
||
</div>
|
||
|
||
<div class="tier tier--data">
|
||
<div class="tier-label">Data</div>
|
||
<div class="tier-body">
|
||
<div class="box box--amber">
|
||
<div class="box-title">MySQL 8 <span class="badge badge--amber">Docker</span></div>
|
||
<ul>
|
||
<li>winstudentgoaltracker DB</li>
|
||
<li>17 tables</li>
|
||
<li>51 stored procedures</li>
|
||
<li>Initialised from db/docker-init/</li>
|
||
</ul>
|
||
</div>
|
||
<div class="box box--amber">
|
||
<div class="box-title">Core Tables</div>
|
||
<ul>
|
||
<li>user, school_district, program</li>
|
||
<li>user_program (roles junction)</li>
|
||
<li>student, user_student</li>
|
||
<li>goal (recursive), benchmark</li>
|
||
<li>progress_event + junction</li>
|
||
<li>refresh_token</li>
|
||
</ul>
|
||
</div>
|
||
<div class="box box--amber">
|
||
<div class="box-title">Procedure Groups</div>
|
||
<ul>
|
||
<li>sp_Student_* (CRUD)</li>
|
||
<li>sp_Goal_* / sp_Benchmark_*</li>
|
||
<li>sp_ProgressEvent_*</li>
|
||
<li>sp_RefreshToken_*</li>
|
||
<li>sp_Program_* / sp_User_*</li>
|
||
<li>sp_ProgressReport_*</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tier-arrow">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
|
||
<span>Outbound HTTP from API tier only</span>
|
||
</div>
|
||
|
||
<div class="tier tier--ext">
|
||
<div class="tier-label">External</div>
|
||
<div class="tier-body">
|
||
<div class="box box--purple">
|
||
<div class="box-title">Ollama LLM <span class="badge badge--purple">AI</span></div>
|
||
<ul>
|
||
<li>llm.opelly.me</li>
|
||
<li>Model: gemma4:e2b</li>
|
||
<li>Benchmark recommendations</li>
|
||
<li>5-min timeout</li>
|
||
</ul>
|
||
</div>
|
||
<div class="box box--purple">
|
||
<div class="box-title">STT Service <span class="badge badge--purple">AI</span></div>
|
||
<ul>
|
||
<li>stt.opelly.me</li>
|
||
<li>Speech-to-text transcription</li>
|
||
<li>Progress event dictation</li>
|
||
<li>5-min timeout</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /arch-diagram -->
|
||
|
||
<!-- ── 2. Auth Flow ── -->
|
||
<div class="asec-title"><span>🔐</span> Two-Phase Authentication Flow</div>
|
||
|
||
<div style="overflow-x:auto; padding-bottom:.5rem;">
|
||
<div class="aflow" style="min-width:900px;">
|
||
|
||
<div class="aflow-step">
|
||
<div class="aflow-num">1</div>
|
||
<div class="aflow-bubble">
|
||
<strong>User Submits Credentials</strong>
|
||
email + password<br>
|
||
<span class="note">POST /api/Auth/Login</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="aflow-connector">
|
||
<svg width="28" height="16" viewBox="0 0 28 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 8h24M18 2l6 6-6 6"/></svg>
|
||
</div>
|
||
|
||
<div class="aflow-step">
|
||
<div class="aflow-num">2</div>
|
||
<div class="aflow-bubble">
|
||
<strong>Phase 1 Response</strong>
|
||
Session token (5 min)<br>+ program list<br>
|
||
<span class="note">Stored in localStorage</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="aflow-connector">
|
||
<svg width="28" height="16" viewBox="0 0 28 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 8h24M18 2l6 6-6 6"/></svg>
|
||
</div>
|
||
|
||
<div class="aflow-step">
|
||
<div class="aflow-num">3</div>
|
||
<div class="aflow-bubble">
|
||
<strong>User Selects Program</strong>
|
||
session token + program ID<br>
|
||
<span class="note">POST /api/Auth/SelectProgram</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="aflow-connector">
|
||
<svg width="28" height="16" viewBox="0 0 28 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 8h24M18 2l6 6-6 6"/></svg>
|
||
</div>
|
||
|
||
<div class="aflow-step">
|
||
<div class="aflow-num">4</div>
|
||
<div class="aflow-bubble">
|
||
<strong>Phase 2 Response</strong>
|
||
JWT (1 min) +<br>Refresh token (30 days)<br>
|
||
<span class="note">JWT includes role + program_id</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="aflow-connector">
|
||
<svg width="28" height="16" viewBox="0 0 28 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 8h24M18 2l6 6-6 6"/></svg>
|
||
</div>
|
||
|
||
<div class="aflow-step">
|
||
<div class="aflow-num">5</div>
|
||
<div class="aflow-bubble">
|
||
<strong>Authenticated Requests</strong>
|
||
Bearer JWT on all API calls<br>
|
||
<span class="note">Auth interceptor injects token</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="aflow-connector">
|
||
<svg width="28" height="16" viewBox="0 0 28 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 8h24M18 2l6 6-6 6"/></svg>
|
||
</div>
|
||
|
||
<div class="aflow-step">
|
||
<div class="aflow-num">6</div>
|
||
<div class="aflow-bubble">
|
||
<strong>Proactive Refresh</strong>
|
||
10 s before JWT expiry:<br>POST /api/Auth/RefreshToken<br>
|
||
<span class="note">Rotated refresh token returned</span>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:.75rem;font-size:.8rem;color:#4b5563;">
|
||
<strong>Token storage:</strong> <code>auth_jwt</code>, <code>auth_refresh_token</code>, <code>auth_session_token</code> — all in <code>localStorage</code>.
|
||
|
|
||
<strong>401 interceptor:</strong> attempts one silent refresh; if that fails, redirects to <code>/login</code>.
|
||
|
|
||
<strong>Refresh token rotation:</strong> each use replaces the old token (tracked via <code>replaced_by_token_id</code>).
|
||
</div>
|
||
|
||
<!-- ── 3. Data Model ── -->
|
||
<div class="asec-title"><span>🗄</span> Core Data Model</div>
|
||
|
||
<div class="entity-grid">
|
||
|
||
<div class="entity">
|
||
<div class="entity-header" style="background:#64748b;">school_district</div>
|
||
<div class="entity-fields">
|
||
<div><span class="pk">PK</span> id_school_district</div>
|
||
<div>name</div>
|
||
<div>contact_email</div>
|
||
<div><span class="field-note">Top-level tenant</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="entity">
|
||
<div class="entity-header" style="background:#2563eb;">program</div>
|
||
<div class="entity-fields">
|
||
<div><span class="pk">PK</span> id_program</div>
|
||
<div><span class="fk">FK</span> id_school_district</div>
|
||
<div>name, description</div>
|
||
<div><span class="field-note">Scope boundary for data</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="entity">
|
||
<div class="entity-header" style="background:#0d9488;">user</div>
|
||
<div class="entity-fields">
|
||
<div><span class="pk">PK</span> id_user</div>
|
||
<div>email, name</div>
|
||
<div>password_hash, password_salt</div>
|
||
<div>locked_until</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="entity">
|
||
<div class="entity-header" style="background:#7c3aed;">user_program <span style="font-size:.6rem;opacity:.8;">(junction)</span></div>
|
||
<div class="entity-fields">
|
||
<div><span class="pk">PK</span> id_user_program</div>
|
||
<div><span class="fk">FK</span> id_user</div>
|
||
<div><span class="fk">FK</span> id_program</div>
|
||
<div><span class="fk">FK</span> id_role</div>
|
||
<div>is_primary, status</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="entity">
|
||
<div class="entity-header" style="background:#d97706;">student</div>
|
||
<div class="entity-fields">
|
||
<div><span class="pk">PK</span> id_student</div>
|
||
<div><span class="fk">FK</span> id_program</div>
|
||
<div>identifier, program_year</div>
|
||
<div>enrollment_date</div>
|
||
<div>next_iep_date</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="entity">
|
||
<div class="entity-header" style="background:#7c3aed;">user_student <span style="font-size:.6rem;opacity:.8;">(junction)</span></div>
|
||
<div class="entity-fields">
|
||
<div><span class="pk">PK</span> id_user_student</div>
|
||
<div><span class="fk">FK</span> id_user</div>
|
||
<div><span class="fk">FK</span> id_student</div>
|
||
<div>is_primary</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="entity">
|
||
<div class="entity-header" style="background:#16a34a;">goal</div>
|
||
<div class="entity-fields">
|
||
<div><span class="pk">PK</span> id_goal</div>
|
||
<div><span class="fk">FK</span> id_goal_parent <span class="field-note">(self-ref)</span></div>
|
||
<div><span class="fk">FK</span> id_student</div>
|
||
<div>description, category</div>
|
||
<div>baseline, target_completion_date</div>
|
||
<div>close_date, achieved</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="entity">
|
||
<div class="entity-header" style="background:#0d9488;">benchmark</div>
|
||
<div class="entity-fields">
|
||
<div><span class="pk">PK</span> id_benchmark</div>
|
||
<div><span class="fk">FK</span> id_goal</div>
|
||
<div>benchmark (description)</div>
|
||
<div>short_name</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="entity">
|
||
<div class="entity-header" style="background:#e11d48;">progress_event</div>
|
||
<div class="entity-fields">
|
||
<div><span class="pk">PK</span> id_progress_event</div>
|
||
<div><span class="fk">FK</span> id_goal</div>
|
||
<div><span class="fk">FK</span> id_user_created</div>
|
||
<div>content (rich text)</div>
|
||
<div>is_sensitive</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="entity">
|
||
<div class="entity-header" style="background:#7c3aed;">progress_event_benchmark <span style="font-size:.6rem;opacity:.8;">(junction)</span></div>
|
||
<div class="entity-fields">
|
||
<div><span class="fk">FK</span> id_progress_event</div>
|
||
<div><span class="fk">FK</span> id_benchmark</div>
|
||
<div><span class="field-note">Links events to benchmarks</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="entity">
|
||
<div class="entity-header" style="background:#64748b;">refresh_token</div>
|
||
<div class="entity-fields">
|
||
<div><span class="pk">PK</span> id_refresh_token</div>
|
||
<div><span class="fk">FK</span> id_user, id_program</div>
|
||
<div>token_hash, token_salt</div>
|
||
<div>expires_at, revoked_at</div>
|
||
<div>replaced_by_token_id</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="entity">
|
||
<div class="entity-header" style="background:#4f46e5;">report_prompt</div>
|
||
<div class="entity-fields">
|
||
<div><span class="pk">PK</span> id_report_prompt</div>
|
||
<div><span class="fk">FK</span> program_id</div>
|
||
<div>report_name</div>
|
||
<div>prompt_template (LLM)</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="rel-summary">
|
||
<strong>Key relationships:</strong>
|
||
school_district <strong>1→N</strong> program ·
|
||
program <strong>1→N</strong> student ·
|
||
student <strong>1→N</strong> goal (recursive parent/child) ·
|
||
goal <strong>1→N</strong> benchmark ·
|
||
goal <strong>1→N</strong> progress_event ·
|
||
progress_event <strong>M↔N</strong> benchmark (junction) ·
|
||
user <strong>M↔N</strong> program (via user_program + role) ·
|
||
user <strong>M↔N</strong> student (via user_student)
|
||
</div>
|
||
|
||
<!-- ── 4. Role Hierarchy ── -->
|
||
<div class="asec-title"><span>👥</span> Role Hierarchy & Permissions</div>
|
||
|
||
<div class="role-and-matrix">
|
||
|
||
<div class="role-pane">
|
||
<div class="pane-label">Role Tiers (highest → lowest)</div>
|
||
<div class="role-pyramid">
|
||
<div class="role-row"><div class="role-chip" style="background:#0f172a;min-width:280px;">super_admin · <span style="font-weight:400;font-size:.7rem;">full platform access</span></div></div>
|
||
<div class="role-row"><div class="role-chip" style="background:#1d4ed8;min-width:240px;">district_admin · <span style="font-weight:400;font-size:.7rem;">manages programs</span></div></div>
|
||
<div class="role-row"><div class="role-chip" style="background:#2563eb;min-width:200px;">program_admin · <span style="font-weight:400;font-size:.7rem;">manages users</span></div></div>
|
||
<div class="role-row"><div class="role-chip" style="background:#16a34a;min-width:160px;">teacher · <span style="font-weight:400;font-size:.7rem;">full student CRUD</span></div></div>
|
||
<div class="role-row"><div class="role-chip" style="background:#d97706;min-width:120px;">paraeducator · <span style="font-weight:400;font-size:.7rem;">log events</span></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="role-pane" style="overflow-x:auto;">
|
||
<div class="pane-label">Sample Permission Matrix</div>
|
||
<table class="matrix">
|
||
<thead>
|
||
<tr>
|
||
<th>Entity / Action</th>
|
||
<th>super_admin</th>
|
||
<th>district_admin</th>
|
||
<th>program_admin</th>
|
||
<th>teacher</th>
|
||
<th>paraeducator</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr><td>Student — Create</td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="deny">Deny</span></td></tr>
|
||
<tr><td>Student — Update</td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td><td><span class="deny">Deny</span></td></tr>
|
||
<tr><td>Goal — Create</td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td><td><span class="deny">Deny</span></td></tr>
|
||
<tr><td>Goal — Update</td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td><td><span class="deny">Deny</span></td></tr>
|
||
<tr><td>ProgressEvent — Create</td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td></tr>
|
||
<tr><td>ProgressEvent — Delete</td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td><td><span class="deny">Deny</span></td></tr>
|
||
<tr><td>Program — Create</td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td><td><span class="deny">Deny</span></td><td><span class="deny">Deny</span></td><td><span class="deny">Deny</span></td></tr>
|
||
<tr><td>User — Create</td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td><td><span class="mine">Mine</span></td><td><span class="deny">Deny</span></td><td><span class="deny">Deny</span></td></tr>
|
||
</tbody>
|
||
</table>
|
||
<div style="font-size:.63rem;color:#9ca3af;margin-top:.5rem;">
|
||
<span class="allow">Allow</span> = full access
|
||
<span class="mine">Mine</span> = own records only
|
||
<span class="deny">Deny</span> = not permitted
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ── 5. Deployment ── -->
|
||
<div class="asec-title"><span>🐳</span> Deployment & Infrastructure</div>
|
||
|
||
<div class="info-cards">
|
||
<div class="info-card">
|
||
<h3>Docker Containers</h3>
|
||
<ul>
|
||
<li>mysql:8 (port 3309)</li>
|
||
<li>.NET API (port 8005)</li>
|
||
<li>Angular / Nginx (port 8006)</li>
|
||
<li>Traefik reverse proxy</li>
|
||
</ul>
|
||
</div>
|
||
<div class="info-card">
|
||
<h3>Docker Networks</h3>
|
||
<ul>
|
||
<li>web — external HTTPS routing</li>
|
||
<li>backend — internal service mesh</li>
|
||
<li>API depends on MySQL healthcheck</li>
|
||
<li>UI depends on API (build time)</li>
|
||
</ul>
|
||
</div>
|
||
<div class="info-card">
|
||
<h3>Environment Config</h3>
|
||
<ul>
|
||
<li>MYSQL_ROOT_PASSWORD</li>
|
||
<li>MYSQL_DATABASE / USER / PASSWORD</li>
|
||
<li>JWT_KEY (signing secret)</li>
|
||
<li>MYSQL_HOST / PORT</li>
|
||
</ul>
|
||
</div>
|
||
<div class="info-card">
|
||
<h3>Frontend Environments</h3>
|
||
<ul>
|
||
<li>Dev: localhost:5000 (API)</li>
|
||
<li>Prod: winapi.opelly.me (API)</li>
|
||
<li>Served by Nginx in production</li>
|
||
<li>Angular CLI dev server locally</li>
|
||
</ul>
|
||
</div>
|
||
<div class="info-card">
|
||
<h3>DB Initialisation</h3>
|
||
<ul>
|
||
<li>Schemas from db/docker-init/</li>
|
||
<li>51 stored procedures</li>
|
||
<li>Health check gates API startup</li>
|
||
<li>No ORM migrations — raw SQL</li>
|
||
</ul>
|
||
</div>
|
||
<div class="info-card">
|
||
<h3>AI / External Services</h3>
|
||
<ul>
|
||
<li>Ollama LLM — llm.opelly.me</li>
|
||
<li>STT service — stt.opelly.me</li>
|
||
<li>Both called from API tier only</li>
|
||
<li>5-minute timeout on both</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── 6. Key Design Decisions ── -->
|
||
<div class="asec-title"><span>⚙️</span> Key Design Decisions</div>
|
||
|
||
<div class="info-cards">
|
||
<div class="info-card">
|
||
<h3>Two-Phase JWT Auth</h3>
|
||
<ul>
|
||
<li>Phase 1: credentials → session token</li>
|
||
<li>Phase 2: program select → scoped JWT</li>
|
||
<li>Prevents data leakage before program chosen</li>
|
||
<li>JWT scoped to one program at a time</li>
|
||
</ul>
|
||
</div>
|
||
<div class="info-card">
|
||
<h3>Refresh Token Rotation</h3>
|
||
<ul>
|
||
<li>New token issued on each refresh</li>
|
||
<li>Old token replaced (not deleted)</li>
|
||
<li>Genealogy tracked via replaced_by</li>
|
||
<li>Prevents replay attacks</li>
|
||
</ul>
|
||
</div>
|
||
<div class="info-card">
|
||
<h3>Program-Scoped Multi-Tenancy</h3>
|
||
<ul>
|
||
<li>JWT contains program_id claim</li>
|
||
<li>All queries filtered by program</li>
|
||
<li>district_admin scoped to district</li>
|
||
<li>super_admin bypasses scope</li>
|
||
</ul>
|
||
</div>
|
||
<div class="info-card">
|
||
<h3>Dapper + Stored Procedures</h3>
|
||
<ul>
|
||
<li>No ORM (Entity Framework)</li>
|
||
<li>Business logic close to data</li>
|
||
<li>Dapper maps rows to C# objects</li>
|
||
<li>51 procedures for full coverage</li>
|
||
</ul>
|
||
</div>
|
||
<div class="info-card">
|
||
<h3>Angular Signals (not RxJS)</h3>
|
||
<ul>
|
||
<li>Auth state as reactive signals</li>
|
||
<li>Computed signals derive user context</li>
|
||
<li>Simpler than Observable chains</li>
|
||
<li>Auto-update dependent UI</li>
|
||
</ul>
|
||
</div>
|
||
<div class="info-card">
|
||
<h3>Centralised Permission Matrix</h3>
|
||
<ul>
|
||
<li>Declarative rules per entity + action</li>
|
||
<li>Allow / MineOnly / Deny granularity</li>
|
||
<li>Evaluated at request time in API</li>
|
||
<li>Single source of truth for authz</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
</section>
|
||
</main>
|
||
</div>
|
||
|
||
<script>
|
||
// ─── ERD Schema Data ───
|
||
const schema = {
|
||
tables: {
|
||
user: {
|
||
columns: [
|
||
{ name: 'id_user', type: 'char(36)', pk: true },
|
||
{ name: 'email', type: 'varchar(255)' },
|
||
{ name: 'name', type: 'varchar(255)' },
|
||
{ name: 'password_hash', type: 'varchar(255)' },
|
||
{ name: 'password_salt', type: 'varchar(255)' },
|
||
{ name: 'password_updated_at', type: 'timestamp' },
|
||
{ name: 'failed_login_attempts', type: 'int' },
|
||
{ name: 'locked_until', type: 'timestamp' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#6ea8fe'
|
||
},
|
||
student: {
|
||
columns: [
|
||
{ name: 'id_student', type: 'char(36)', pk: true },
|
||
{ name: 'id_program', type: 'char(36)', fk: 'program' },
|
||
{ name: 'identifier', type: 'varchar(50)' },
|
||
{ name: 'program_year', type: 'int' },
|
||
{ name: 'enrollment_date', type: 'date' },
|
||
{ name: 'next_iep_date', type: 'date' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#66d9a0'
|
||
},
|
||
goal: {
|
||
columns: [
|
||
{ name: 'id_goal', type: 'char(36)', pk: true },
|
||
{ name: 'id_goal_parent', type: 'char(36)', fk: 'goal' },
|
||
{ name: 'id_student', type: 'char(36)', fk: 'student' },
|
||
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
|
||
{ name: 'description', type: 'text' },
|
||
{ name: 'category', type: 'varchar(255)' },
|
||
{ name: 'baseline', type: 'text' },
|
||
{ name: 'target_completion_date', type: 'date' },
|
||
{ name: 'close_date', type: 'date' },
|
||
{ name: 'achieved', type: 'tinyint(1)' },
|
||
{ name: 'close_notes', type: 'text' },
|
||
{ name: 'created_at', type: 'timestamp' },
|
||
{ name: 'updated_at', type: 'timestamp' }
|
||
],
|
||
color: '#f0b866'
|
||
},
|
||
benchmark: {
|
||
columns: [
|
||
{ name: 'id_benchmark', type: 'char(36)', pk: true },
|
||
{ name: 'id_goal', type: 'char(36)', fk: 'goal' },
|
||
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
|
||
{ name: 'benchmark', type: 'text' },
|
||
{ name: 'short_name', type: 'varchar(50)' },
|
||
{ name: 'created_at', type: 'datetime' },
|
||
{ name: 'updated_at', type: 'datetime' }
|
||
],
|
||
color: '#f0b866'
|
||
},
|
||
progress_event: {
|
||
columns: [
|
||
{ name: 'id_progress_event', type: 'char(36)', pk: true },
|
||
{ name: 'id_goal', type: 'char(36)', fk: 'goal' },
|
||
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
|
||
{ name: 'content', type: 'text' },
|
||
{ name: 'is_sensitive', type: 'tinyint(1)' },
|
||
{ name: 'created_at', type: 'timestamp' },
|
||
{ name: 'updated_at', type: 'timestamp' }
|
||
],
|
||
color: '#c792ea'
|
||
},
|
||
progress_event_benchmark: {
|
||
columns: [
|
||
{ name: 'id_progress_event_benchmark', type: 'char(36)', pk: true },
|
||
{ name: 'id_progress_event', type: 'char(36)', fk: 'progress_event' },
|
||
{ name: 'id_benchmark', type: 'char(36)', fk: 'benchmark' },
|
||
{ name: 'created_at', type: 'datetime' }
|
||
],
|
||
color: '#c792ea'
|
||
},
|
||
progress_report: {
|
||
columns: [
|
||
{ name: 'id_progress_report', type: 'char(36)', pk: true },
|
||
{ name: 'id_student', type: 'char(36)', fk: 'student' },
|
||
{ name: 'id_goal', type: 'char(36)', fk: 'goal' },
|
||
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
|
||
{ name: 'period', type: 'varchar(10)' },
|
||
{ name: 'year', type: 'int' },
|
||
{ name: 'summary', type: 'text' },
|
||
{ name: 'generated_at', type: 'timestamp' }
|
||
],
|
||
color: '#c792ea'
|
||
},
|
||
health_note: {
|
||
columns: [
|
||
{ name: 'id_health_note', type: 'char(36)', pk: true },
|
||
{ name: 'id_student', type: 'char(36)', fk: 'student' },
|
||
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
|
||
{ name: 'content', type: 'text' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#66d9a0'
|
||
},
|
||
program: {
|
||
columns: [
|
||
{ name: 'id_program', type: 'char(36)', pk: true },
|
||
{ name: 'id_school_district', type: 'char(36)', fk: 'school_district' },
|
||
{ name: 'name', type: 'varchar(255)' },
|
||
{ name: 'description', type: 'text' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#f07178'
|
||
},
|
||
school_district: {
|
||
columns: [
|
||
{ name: 'id_school_district', type: 'char(36)', pk: true },
|
||
{ name: 'name', type: 'varchar(255)' },
|
||
{ name: 'contact_email', type: 'varchar(255)' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#f07178'
|
||
},
|
||
role: {
|
||
columns: [
|
||
{ name: 'id_role', type: 'char(36)', pk: true },
|
||
{ name: 'name', type: 'varchar(100)' },
|
||
{ name: 'internal_name', type: 'varchar(100)' },
|
||
{ name: 'description', type: 'text' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#6ea8fe'
|
||
},
|
||
user_program: {
|
||
columns: [
|
||
{ name: 'id_user_program', type: 'char(36)', pk: true },
|
||
{ name: 'id_user', type: 'char(36)', fk: 'user' },
|
||
{ name: 'id_program', type: 'char(36)', fk: 'program' },
|
||
{ name: 'id_role', type: 'char(36)', fk: 'role' },
|
||
{ name: 'is_primary', type: 'tinyint(1)' },
|
||
{ name: 'status', type: 'varchar(20)' },
|
||
{ name: 'joined_at', type: 'timestamp' }
|
||
],
|
||
color: '#6ea8fe'
|
||
},
|
||
user_student: {
|
||
columns: [
|
||
{ name: 'id_user_student', type: 'char(36)', pk: true },
|
||
{ name: 'id_user', type: 'char(36)', fk: 'user' },
|
||
{ name: 'id_student', type: 'char(36)', fk: 'student' },
|
||
{ name: 'is_primary', type: 'tinyint(1)' }
|
||
],
|
||
color: '#6ea8fe'
|
||
},
|
||
password_history: {
|
||
columns: [
|
||
{ name: 'id_password_history', type: 'char(36)', pk: true },
|
||
{ name: 'id_user', type: 'char(36)', fk: 'user' },
|
||
{ name: 'password_hash', type: 'varchar(255)' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#5a5f75'
|
||
},
|
||
password_reset_token: {
|
||
columns: [
|
||
{ name: 'id_password_reset_token', type: 'char(36)', pk: true },
|
||
{ name: 'id_user', type: 'char(36)', fk: 'user' },
|
||
{ name: 'token_hash', type: 'varchar(255)' },
|
||
{ name: 'expires_at', type: 'timestamp' },
|
||
{ name: 'created_at', type: 'timestamp' },
|
||
{ name: 'used_at', type: 'timestamp' },
|
||
{ name: 'invalidated_at', type: 'timestamp' },
|
||
{ name: 'request_ip', type: 'varchar(45)' }
|
||
],
|
||
color: '#5a5f75'
|
||
},
|
||
refresh_token: {
|
||
columns: [
|
||
{ name: 'id_refresh_token', type: 'char(36)', pk: true },
|
||
{ name: 'id_user', type: 'char(36)', fk: 'user' },
|
||
{ name: 'id_program', type: 'char(36)', fk: 'program' },
|
||
{ name: 'token_hash', type: 'varchar(512)' },
|
||
{ name: 'token_salt', type: 'varchar(512)' },
|
||
{ name: 'expires_at', type: 'timestamp' },
|
||
{ name: 'last_used_at', type: 'timestamp' },
|
||
{ name: 'revoked_at', type: 'timestamp' },
|
||
{ name: 'device_info', type: 'varchar(255)' },
|
||
{ name: 'user_agent', type: 'varchar(512)' },
|
||
{ name: 'replaced_by_token_id', type: 'char(36)', fk: 'refresh_token' },
|
||
{ name: 'created_at', type: 'timestamp' },
|
||
{ name: 'updated_at', type: 'timestamp' }
|
||
],
|
||
color: '#5a5f75'
|
||
},
|
||
ReportPrompt: {
|
||
columns: [
|
||
{ name: 'id_ReportPrompt', type: 'char(36)', pk: true },
|
||
{ name: 'prompt', type: 'text' },
|
||
{ name: 'reportname', type: 'char(100)' },
|
||
{ name: 'id_program', type: 'char(36)', fk: 'program' }
|
||
],
|
||
color: '#c792ea'
|
||
}
|
||
}
|
||
};
|
||
|
||
const relationships = [
|
||
{ from: 'goal', fromCol: 'id_goal_parent', to: 'goal', toCol: 'id_goal', label: 'parent' },
|
||
{ from: 'goal', fromCol: 'id_student', to: 'student', toCol: 'id_student' },
|
||
{ from: 'goal', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
|
||
{ from: 'health_note', fromCol: 'id_student', to: 'student', toCol: 'id_student' },
|
||
{ from: 'health_note', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
|
||
{ from: 'password_history', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
|
||
{ from: 'password_reset_token', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
|
||
{ from: 'program', fromCol: 'id_school_district', to: 'school_district', toCol: 'id_school_district' },
|
||
{ from: 'progress_event', fromCol: 'id_goal', to: 'goal', toCol: 'id_goal' },
|
||
{ from: 'progress_event', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
|
||
{ from: 'progress_event_benchmark', fromCol: 'id_progress_event', to: 'progress_event', toCol: 'id_progress_event' },
|
||
{ from: 'progress_event_benchmark', fromCol: 'id_benchmark', to: 'benchmark', toCol: 'id_benchmark' },
|
||
{ from: 'progress_report', fromCol: 'id_student', to: 'student', toCol: 'id_student' },
|
||
{ from: 'progress_report', fromCol: 'id_goal', to: 'goal', toCol: 'id_goal' },
|
||
{ from: 'progress_report', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
|
||
{ from: 'refresh_token', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
|
||
{ from: 'refresh_token', fromCol: 'id_program', to: 'program', toCol: 'id_program' },
|
||
{ from: 'refresh_token', fromCol: 'replaced_by_token_id', to: 'refresh_token', toCol: 'id_refresh_token', label: 'replaces' },
|
||
{ from: 'benchmark', fromCol: 'id_goal', to: 'goal', toCol: 'id_goal' },
|
||
{ from: 'benchmark', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
|
||
{ from: 'student', fromCol: 'id_program', to: 'program', toCol: 'id_program' },
|
||
{ from: 'user_program', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
|
||
{ from: 'user_program', fromCol: 'id_program', to: 'program', toCol: 'id_program' },
|
||
{ from: 'user_program', fromCol: 'id_role', to: 'role', toCol: 'id_role' },
|
||
{ from: 'user_student', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
|
||
{ from: 'user_student', fromCol: 'id_student', to: 'student', toCol: 'id_student' },
|
||
{ from: 'ReportPrompt', fromCol: 'id_program', to: 'program', toCol: 'id_program' }
|
||
];
|
||
|
||
document.getElementById('rel-count').textContent = relationships.length;
|
||
|
||
const positions = {
|
||
user: { x: 100, y: 300 },
|
||
role: { x: 100, y: 50 },
|
||
user_program: { x: 380, y: 80 },
|
||
user_student: { x: 380, y: 340 },
|
||
school_district: { x: 680, y: 50 },
|
||
program: { x: 680, y: 260 },
|
||
student: { x: 680, y: 500 },
|
||
goal: { x: 1050, y: 260 },
|
||
benchmark: { x: 1050, y: 50 },
|
||
progress_event: { x: 1380, y: 100 },
|
||
progress_event_benchmark: { x: 1380, y: 340 },
|
||
progress_report: { x: 1050, y: 570 },
|
||
health_note: { x: 680, y: 760 },
|
||
ReportPrompt: { x: 1380, y: 570 },
|
||
password_history: { x: 100, y: 620 },
|
||
password_reset_token: { x: 100, y: 800 },
|
||
refresh_token: { x: 380, y: 620 }
|
||
};
|
||
|
||
const canvas = document.getElementById('canvas');
|
||
const svgLines = document.getElementById('lines');
|
||
const container = document.getElementById('canvas-container');
|
||
const tableElements = {};
|
||
let scale = 0.85;
|
||
let panX = 50, panY = 20;
|
||
let showLabels = true;
|
||
|
||
function renderTables() {
|
||
for (const [tableName, table] of Object.entries(schema.tables)) {
|
||
const pos = positions[tableName] || { x: 0, y: 0 };
|
||
const el = document.createElement('div');
|
||
el.className = 'table-node';
|
||
el.id = `table-${tableName}`;
|
||
el.style.left = pos.x + 'px';
|
||
el.style.top = pos.y + 'px';
|
||
|
||
const headerColor = table.color || '#6ea8fe';
|
||
let html = `
|
||
<div class="table-header">
|
||
<div class="table-icon" style="background:${headerColor}22;color:${headerColor}">⬡</div>
|
||
<span class="table-name">${tableName}</span>
|
||
</div>
|
||
<div class="table-body">
|
||
`;
|
||
for (const col of table.columns) {
|
||
const isPk = col.pk;
|
||
const isFk = col.fk;
|
||
let badge = '<span class="col-badge empty"></span>';
|
||
if (isPk) badge = '<span class="col-badge pk">PK</span>';
|
||
else if (isFk) badge = '<span class="col-badge fk">FK</span>';
|
||
|
||
let nameClass = 'col-name';
|
||
if (isPk) nameClass += ' pk-name';
|
||
else if (isFk) nameClass += ' fk-name';
|
||
|
||
html += `
|
||
<div class="column-row" data-col="${col.name}">
|
||
${badge}
|
||
<span class="${nameClass}">${col.name}</span>
|
||
<span class="col-type">${col.type}</span>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
el.innerHTML = html;
|
||
canvas.appendChild(el);
|
||
tableElements[tableName] = el;
|
||
makeDraggable(el, tableName);
|
||
}
|
||
}
|
||
|
||
function getColumnY(tableName, colName) {
|
||
const el = tableElements[tableName];
|
||
if (!el) return 0;
|
||
const row = el.querySelector(`[data-col="${colName}"]`);
|
||
if (!row) return el.offsetTop + 20;
|
||
return el.offsetTop + row.offsetTop + row.offsetHeight / 2;
|
||
}
|
||
|
||
function drawRelationships() {
|
||
svgLines.innerHTML = '';
|
||
|
||
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
||
marker.setAttribute('id', 'arrowhead');
|
||
marker.setAttribute('markerWidth', '8');
|
||
marker.setAttribute('markerHeight', '6');
|
||
marker.setAttribute('refX', '8');
|
||
marker.setAttribute('refY', '3');
|
||
marker.setAttribute('orient', 'auto');
|
||
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||
poly.setAttribute('points', '0 0, 8 3, 0 6');
|
||
poly.setAttribute('class', 'rel-marker');
|
||
marker.appendChild(poly);
|
||
defs.appendChild(marker);
|
||
|
||
const markerH = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
||
markerH.setAttribute('id', 'arrowhead-hl');
|
||
markerH.setAttribute('markerWidth', '8');
|
||
markerH.setAttribute('markerHeight', '6');
|
||
markerH.setAttribute('refX', '8');
|
||
markerH.setAttribute('refY', '3');
|
||
markerH.setAttribute('orient', 'auto');
|
||
const polyH = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||
polyH.setAttribute('points', '0 0, 8 3, 0 6');
|
||
polyH.setAttribute('class', 'rel-marker highlighted');
|
||
markerH.appendChild(polyH);
|
||
defs.appendChild(markerH);
|
||
svgLines.appendChild(defs);
|
||
|
||
for (const rel of relationships) {
|
||
const fromEl = tableElements[rel.from];
|
||
const toEl = tableElements[rel.to];
|
||
if (!fromEl || !toEl) continue;
|
||
|
||
const fromY = getColumnY(rel.from, rel.fromCol);
|
||
const toY = getColumnY(rel.to, rel.toCol);
|
||
const fromLeft = fromEl.offsetLeft;
|
||
const fromRight = fromEl.offsetLeft + fromEl.offsetWidth;
|
||
const toLeft = toEl.offsetLeft;
|
||
const toRight = toEl.offsetLeft + toEl.offsetWidth;
|
||
|
||
let x1, x2;
|
||
if (rel.from === rel.to) {
|
||
x1 = fromRight;
|
||
x2 = fromRight;
|
||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
const loopSize = 40;
|
||
const d = `M ${x1} ${fromY} C ${x1 + loopSize} ${fromY - loopSize}, ${x2 + loopSize} ${toY + loopSize}, ${x2} ${toY}`;
|
||
path.setAttribute('d', d);
|
||
path.setAttribute('class', 'rel-line');
|
||
path.setAttribute('marker-end', 'url(#arrowhead)');
|
||
path.dataset.from = rel.from;
|
||
path.dataset.to = rel.to;
|
||
svgLines.appendChild(path);
|
||
|
||
if (showLabels && rel.label) {
|
||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
text.setAttribute('x', x1 + loopSize + 4);
|
||
text.setAttribute('y', (fromY + toY) / 2);
|
||
text.setAttribute('class', 'rel-label');
|
||
text.textContent = rel.label;
|
||
svgLines.appendChild(text);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
const fromCX = (fromLeft + fromRight) / 2;
|
||
const toCX = (toLeft + toRight) / 2;
|
||
if (fromCX < toCX) { x1 = fromRight; x2 = toLeft; }
|
||
else { x1 = fromLeft; x2 = toRight; }
|
||
|
||
const dx = Math.abs(x2 - x1);
|
||
const cpOffset = Math.max(40, dx * 0.35);
|
||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
const cp1x = x1 < x2 ? x1 + cpOffset : x1 - cpOffset;
|
||
const cp2x = x1 < x2 ? x2 - cpOffset : x2 + cpOffset;
|
||
const d = `M ${x1} ${fromY} C ${cp1x} ${fromY}, ${cp2x} ${toY}, ${x2} ${toY}`;
|
||
path.setAttribute('d', d);
|
||
path.setAttribute('class', 'rel-line');
|
||
path.setAttribute('marker-end', 'url(#arrowhead)');
|
||
path.dataset.from = rel.from;
|
||
path.dataset.to = rel.to;
|
||
svgLines.appendChild(path);
|
||
|
||
if (showLabels && rel.label) {
|
||
const mx = (x1 + x2) / 2;
|
||
const my = (fromY + toY) / 2 - 6;
|
||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
text.setAttribute('x', mx);
|
||
text.setAttribute('y', my);
|
||
text.setAttribute('class', 'rel-label');
|
||
text.setAttribute('text-anchor', 'middle');
|
||
text.textContent = rel.label;
|
||
svgLines.appendChild(text);
|
||
}
|
||
}
|
||
}
|
||
|
||
function makeDraggable(el, tableName) {
|
||
let startX, startY, origX, origY;
|
||
el.addEventListener('mousedown', (e) => {
|
||
if (e.button !== 0) return;
|
||
e.stopPropagation();
|
||
el.classList.add('dragging');
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
origX = el.offsetLeft;
|
||
origY = el.offsetTop;
|
||
|
||
function onMove(e) {
|
||
const dx = (e.clientX - startX) / scale;
|
||
const dy = (e.clientY - startY) / scale;
|
||
el.style.left = (origX + dx) + 'px';
|
||
el.style.top = (origY + dy) + 'px';
|
||
positions[tableName].x = origX + dx;
|
||
positions[tableName].y = origY + dy;
|
||
drawRelationships();
|
||
updateMinimap();
|
||
}
|
||
function onUp() {
|
||
el.classList.remove('dragging');
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
});
|
||
el.addEventListener('mouseenter', () => highlightTable(tableName));
|
||
el.addEventListener('mouseleave', () => clearHighlights());
|
||
}
|
||
|
||
function highlightTable(tableName) {
|
||
const lines = svgLines.querySelectorAll('.rel-line');
|
||
lines.forEach(line => {
|
||
if (line.dataset.from === tableName || line.dataset.to === tableName) {
|
||
line.classList.add('highlighted');
|
||
line.setAttribute('marker-end', 'url(#arrowhead-hl)');
|
||
} else {
|
||
line.style.opacity = '0.15';
|
||
}
|
||
});
|
||
}
|
||
|
||
function clearHighlights() {
|
||
const lines = svgLines.querySelectorAll('.rel-line');
|
||
lines.forEach(line => {
|
||
line.classList.remove('highlighted');
|
||
line.setAttribute('marker-end', 'url(#arrowhead)');
|
||
line.style.opacity = '';
|
||
});
|
||
}
|
||
|
||
let isPanning = false, panStartX, panStartY;
|
||
|
||
container.addEventListener('mousedown', (e) => {
|
||
if (e.target !== container && e.target !== canvas && e.target.tagName !== 'svg') return;
|
||
isPanning = true;
|
||
panStartX = e.clientX - panX;
|
||
panStartY = e.clientY - panY;
|
||
container.style.cursor = 'grabbing';
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!isPanning) return;
|
||
panX = e.clientX - panStartX;
|
||
panY = e.clientY - panStartY;
|
||
applyTransform();
|
||
updateMinimap();
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
isPanning = false;
|
||
container.style.cursor = 'grab';
|
||
});
|
||
|
||
container.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? 0.92 : 1.08;
|
||
const newScale = Math.max(0.2, Math.min(2, scale * delta));
|
||
const rect = container.getBoundingClientRect();
|
||
const cx = e.clientX - rect.left;
|
||
const cy = e.clientY - rect.top;
|
||
panX = cx - (cx - panX) * (newScale / scale);
|
||
panY = cy - (cy - panY) * (newScale / scale);
|
||
scale = newScale;
|
||
applyTransform();
|
||
updateMinimap();
|
||
}, { passive: false });
|
||
|
||
function applyTransform() {
|
||
canvas.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
|
||
}
|
||
|
||
function zoomIn() { scale = Math.min(2, scale * 1.2); applyTransform(); updateMinimap(); }
|
||
function zoomOut() { scale = Math.max(0.2, scale * 0.8); applyTransform(); updateMinimap(); }
|
||
|
||
function resetView() {
|
||
scale = 0.85; panX = 50; panY = 20;
|
||
applyTransform(); updateMinimap();
|
||
}
|
||
|
||
function toggleLabels() {
|
||
showLabels = !showLabels;
|
||
drawRelationships();
|
||
}
|
||
|
||
const minimapCanvas = document.getElementById('minimap-canvas');
|
||
const mCtx = minimapCanvas.getContext('2d');
|
||
|
||
function updateMinimap() {
|
||
const mw = 180, mh = 120;
|
||
minimapCanvas.width = mw * 2;
|
||
minimapCanvas.height = mh * 2;
|
||
minimapCanvas.style.width = mw + 'px';
|
||
minimapCanvas.style.height = mh + 'px';
|
||
mCtx.scale(2, 2);
|
||
mCtx.clearRect(0, 0, mw, mh);
|
||
mCtx.fillStyle = '#0f1117';
|
||
mCtx.fillRect(0, 0, mw, mh);
|
||
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const [name, pos] of Object.entries(positions)) {
|
||
const el = tableElements[name];
|
||
if (!el) continue;
|
||
minX = Math.min(minX, pos.x); minY = Math.min(minY, pos.y);
|
||
maxX = Math.max(maxX, pos.x + el.offsetWidth); maxY = Math.max(maxY, pos.y + el.offsetHeight);
|
||
}
|
||
const padding = 40;
|
||
minX -= padding; minY -= padding; maxX += padding; maxY += padding;
|
||
const scaleX = mw / (maxX - minX);
|
||
const scaleY = mh / (maxY - minY);
|
||
const ms = Math.min(scaleX, scaleY);
|
||
|
||
for (const [name, pos] of Object.entries(positions)) {
|
||
const el = tableElements[name];
|
||
if (!el) continue;
|
||
const color = schema.tables[name]?.color || '#6ea8fe';
|
||
mCtx.fillStyle = color + '60';
|
||
mCtx.fillRect((pos.x - minX) * ms, (pos.y - minY) * ms, el.offsetWidth * ms, el.offsetHeight * ms);
|
||
}
|
||
|
||
const rect = container.getBoundingClientRect();
|
||
const vx = (-panX / scale - minX) * ms;
|
||
const vy = (-panY / scale - minY) * ms;
|
||
const vw = (rect.width / scale) * ms;
|
||
const vh = (rect.height / scale) * ms;
|
||
mCtx.strokeStyle = '#6ea8fe';
|
||
mCtx.lineWidth = 1;
|
||
mCtx.strokeRect(vx, vy, vw, vh);
|
||
}
|
||
|
||
renderTables();
|
||
drawRelationships();
|
||
applyTransform();
|
||
setTimeout(updateMinimap, 100);
|
||
window.addEventListener('resize', updateMinimap);
|
||
</script>
|
||
</body>
|
||
</html>
|