diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e9b7104 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,257 @@ +# DP Jobs Kanban Board + +A Kanban Board web application running on IIS (Windows). Uses the **RouteKit** framework (front-controller pattern) with Keycloak OpenID Connect for authentication. Database is Microsoft Access (`.accdb`). No Node, npm, or build step — everything is server-side VBScript served by IIS. + +## Project layout + +``` +public/ IIS site root — point IIS here + Default.asp Front controller and route table + web.config App settings, URL rewrite rules + +core/ Framework internals — do not modify + autoload_core.asp + mvc.asp + router.wsc + lib.*.asp Core libraries + helpers.asp Global utility functions (always available) + +app/ + controllers/ Controller classes (one per feature area) + views/ View partials (folders match controller name) + shared/ Header, footer, layout partials + models/ POBOs (plain-old business objects) + repositories/ Data access classes + +db/ + migrations/ Sequential migration scripts + webdata.accdb Access database + +tests/ Dev-only aspunit test harness (separate IIS app) +scripts/ VBScript code generators +``` + +## Core libraries (core/lib.*.asp) + +| File | Purpose | +|---|---| +| `lib.Keycloak.asp` | OpenID Connect / Keycloak auth helper | +| `lib.Routes.asp` | URL generation helpers (`Routes()` singleton) | +| `lib.ControllerRegistry.asp` | Controller whitelist (security) | +| `lib.DAL.asp` | Database access layer (`DAL` singleton) | +| `lib.Collections.asp` | `LinkedList_Class` and other collection types | +| `lib.Enumerable.asp` | Collection iteration helpers | +| `lib.Validations.asp` | Input validation | +| `lib.Flash.asp` | Flash message helpers | +| `lib.FormCache.asp` | Form value re-population | +| `lib.HTML.asp` | HTML rendering helpers | +| `lib.HTML.Security.asp` | XSS escaping — `H()` function | +| `lib.Strings.asp` | String utilities | +| `lib.Automapper.asp` | `Automapper.AutoMap(rs, "POBO_TableName")` | +| `lib.json.asp` | JSON parsing/serialization | + +## Global helper functions (core/helpers.asp) + +Always available — never re-implement them. + +| Function | Signature | Purpose | +|---|---|---| +| `H` | `H(s)` | XSS-safe HTML encode — use on all user data rendered to HTML | +| `GenerateSlug` | `GenerateSlug(title)` | Converts title to safe URL slug | +| `GetRawJsonFromRequest` | `GetRawJsonFromRequest()` | Reads raw JSON body from AJAX POST | +| `GetAppSetting` | `GetAppSetting(key)` | Reads value from `web.config` appSettings | +| `IIf` | `IIf(condition, trueVal, falseVal)` | Inline conditional | +| `Destroy` | `Destroy(obj)` | Safely closes and sets object to Nothing | +| `FormatDateForSql` | `FormatDateForSql(date)` | Formats VBScript date as SQL datetime string | +| `Active` | `Active(controllerName)` | Returns `"active"` if current request maps to that controller | + +## Router — URL parameters + +Routes registered in [public/Default.asp](public/Default.asp) with `:param` segments. + +```vbscript +router.AddRoute "GET", "/boards", "BoardsController", "Index" +router.AddRoute "POST", "/boards", "BoardsController", "Store" +router.AddRoute "GET", "/board/:slug", "BoardsController", "Show" +router.AddRoute "POST", "/cards/:id/move", "CardsController", "Move" +``` + +Controller action receives URL params as arguments in order: + +```vbscript +Public Sub Show(slug) : End Sub +Public Sub Move(id) : End Sub +``` + +URL generation: + +```vbscript +Routes.UrlTo "Boards", "Index", Empty +Routes.UrlToWithParams "Boards", "Show", Array("my-board"), Empty +Routes.UrlTo "Boards", "Index", Array("page", 2) +``` + +## Wiring up a new controller — checklist + +1. Generate: `cscript //nologo scripts\generateController.vbs MyController "Index;Show(slug);Store"` +2. Move file to `app/controllers/` +3. Register in [core/lib.ControllerRegistry.asp](core/lib.ControllerRegistry.asp): `RegisterController "mycontroller"` +4. Include in [app/controllers/autoload_controllers.asp](app/controllers/autoload_controllers.asp): `` +5. Add routes in [public/Default.asp](public/Default.asp) +6. Create views in `app/views/MyController/` + +## POBO pattern (app/models/) + +```vbscript +Class POBO_boards + Public Properties ' array of all column names — required by Automapper + Private p_id, p_name, p_slug, p_created_at, p_created_by, p_updated_at, p_updated_by + + Private Sub Class_Initialize() + Properties = Array("id","name","slug","created_at","created_by","updated_at","updated_by") + End Sub + + Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property + Public Property Get TableName() : TableName = "boards" : End Property + + Public Property Get id() : id = p_id : End Property + Public Property Let id(v) : p_id = CDbl(v) : End Property + ' ... remaining Get/Let pairs follow same pattern +End Class +``` + +Private backing fields use `p_` prefix. `Properties`, `PrimaryKey`, and `TableName` are required. + +## Repository pattern (app/repositories/) + +Uses `DAL` singleton and `Automapper`. Key methods: `FindByID`, `GetAll`, `Find`, `AddNew`, `Update`, `Delete`. After insert, read identity with `SELECT @@IDENTITY AS NewID`. Expose as a singleton function (e.g. `boards_Repository()`). + +## Controller pattern (app/controllers/) + +```vbscript +Class BoardsController_Class + Private m_useLayout, m_title + Private Sub Class_Initialize() : m_useLayout = True : m_title = "Boards" : End Sub + + Public Sub Index() + If Not KeycloakRequireLogin("") Then Exit Sub + End Sub + + Public Sub Move(id) ' JSON/AJAX action + m_useLayout = False + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" : Exit Sub + End If + End Sub +End Class +``` + +JSON/AJAX actions must set `m_useLayout = False` and `Response.ContentType = "application/json"`. + +## Audit columns + +Every table must include `created_at`, `created_by`, `updated_at`, `updated_by`. + +```vbscript +' Insert +Dim currentUser : Set currentUser = KeycloakCurrentUser() +Dim currentUsername : currentUsername = "" +If Not currentUser Is Nothing Then currentUsername = currentUser.Item("preferred_username") +model.created_at = Now() : model.created_by = currentUsername +model.updated_at = Now() : model.updated_by = currentUsername +repo.AddNew model + +' Update — never touch created_at / created_by +model.updated_at = Now() : model.updated_by = currentUsername +repo.Update model +``` + +## Authentication (Keycloak) + +```vbscript +KeycloakRequireLogin("") ' Gate full-page actions +KeycloakIsLoggedIn() ' Use for JSON/AJAX actions +KeycloakCurrentUser() ' Returns userinfo Dictionary (preferred_username, email, name, sub) +KeycloakHasRealmRole("admin") ' Role check +KeycloakLogout("") ' Clear session and redirect +``` + +## LinkedList_Class — correct traversal + +Never use `.First`, `.Last`, `.m_first`, `.m_next`, or `.m_value` — they are private internals. + +```vbscript +Dim iter : Set iter = myList.Iterator() +Do While iter.HasNext() + Set item = iter.GetNext() +Loop +``` + +Or convert: `Dim arr : arr = myList.TO_Array()` + +## AJAX form data — always URLSearchParams, never FormData + +Classic ASP `Request.Form` only parses `application/x-www-form-urlencoded`. `new FormData()` sends `multipart/form-data` which Classic ASP silently ignores. + +```javascript +var params = new URLSearchParams(); +Object.keys(data).forEach(function(k) { params.append(k, data[k]); }); +fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) +``` + +`Request.BinaryRead` / `GetRawJsonFromRequest()` and `Request.Form` are mutually exclusive in the same action — never mix them. + +## View files — Classic ASP scoping rule + +View files are SSI-included inside the controller action's Sub scope. Never define `Function` or `Sub` inside a view file — VBScript forbids nested procedure definitions (`Syntax error 800a03ea`). Keep views to pure HTML rendering: `<%= %>`, `H()`, simple `If`/`For`/`Do` blocks. + +## Database DDL — Access/Jet reserved words + +Always bracket every identifier. Use `migration.ExecuteSQL` for all DDL. Never use `migration.CreateTable` or `migration.CreateIndex`. + +```vbscript +migration.ExecuteSQL "CREATE TABLE [board_columns] ([id] AUTOINCREMENT PRIMARY KEY, [name] VARCHAR(255), [position] INTEGER)" +``` + +Common reserved words: `COLUMNS`, `NAME`, `POSITION`, `VALUE`, `DATE`, `KEY`, `LEVEL`, `BY`. + +## Running migrations (32-bit only) + +``` +C:\Windows\SysWOW64\cscript.exe //nologo scripts\runMigrations.vbs up +``` + +IIS app pool must have `enable32BitAppOnWin64="true"`. Use 32-bit IIS Express: `%ProgramFiles(x86)%\IIS Express\iisexpress.exe`. + +If `migration.DB.Execute/Query` fails in standalone runner, provide a `migration.Connection` + `ADODB.Command` fallback. + +## Flash messages + +```vbscript +Flash().AddError "Something went wrong" +flash.Success = "Saved successfully" +``` + +Never use `Flash().SetError` or `Flash().SetSuccess` — those methods do not exist (Error 438). + +## Things to avoid + +- Do not modify files under `core/` — framework internals. +- Do not add controllers without registering in `ControllerRegistry`. +- Do not commit real `KeycloakClientSecret` values. +- Do not add test routes/pages under `public/`. +- Always use `H()` when rendering user-supplied data (XSS prevention). +- Never write your own slug generator — use `GenerateSlug()`. +- Never use `Private Const` or `Public Const` inside a VBScript class — use a `Private Function` returning the value instead. +- Never run migrations with 64-bit cscript on this machine. +- Never mix `GetRawJsonFromRequest()` with `Request.Form` in the same action. +- Never call `.First`/`.Last`/`.m_first`/`.m_next`/`.m_value` on a `LinkedList_Class`. +- Always set all four audit columns on every insert and update. + +## Requirements + +- Windows Server / Windows with IIS, Classic ASP enabled +- IIS URL Rewrite module +- Microsoft Access Database Engine (ACE OLEDB 12.0) — 32-bit +- Keycloak server (for auth flows) diff --git a/agents.md b/agents.md index b1fe094..be7b37b 100644 --- a/agents.md +++ b/agents.md @@ -1,8 +1,8 @@ -# Agents Guide — RouteKit Classic ASP / Keycloak Test +# Agents Guide — DP Jobs Kanban Board ## What this project is -A Classic ASP MVC web application running on IIS (Windows). It uses the **RouteKit** framework (front-controller pattern) with Keycloak OpenID Connect for authentication. The database is Microsoft Access (`.accdb`). There is no Node, npm, or build step — everything is server-side VBScript served by IIS. +A Kanban Board web application running on IIS (Windows). It uses the **RouteKit** framework (front-controller pattern) with Keycloak OpenID Connect for authentication. The database is Microsoft Access (`.accdb`). There is no Node, npm, or build step — everything is server-side VBScript served by IIS. --- @@ -18,6 +18,7 @@ core/ Framework internals — do not modify mvc.asp router.wsc lib.*.asp Core libraries (see below) + helpers.asp Global utility functions (always available) app/ controllers/ Controller classes (one per feature area) @@ -41,19 +42,19 @@ scripts/ VBScript code generators | File | Purpose | |---|---| | `lib.Keycloak.asp` | OpenID Connect / Keycloak auth helper | -| `lib.Routes.asp` | Route registration and URL helpers | +| `lib.Routes.asp` | URL generation helpers (`Routes()` singleton) | | `lib.ControllerRegistry.asp` | Controller whitelist (security) | -| `lib.DAL.asp` | Database access layer | +| `lib.DAL.asp` | Database access layer (`DAL` singleton) | | `lib.Data.asp` | Data helpers | -| `lib.Collections.asp` | Dictionary/list helpers | +| `lib.Collections.asp` | `LinkedList_Class` and other collection types | | `lib.Enumerable.asp` | Collection iteration helpers | | `lib.Validations.asp` | Input validation | | `lib.Flash.asp` | Flash message helpers | | `lib.FormCache.asp` | Form value re-population | | `lib.HTML.asp` | HTML rendering helpers | -| `lib.HTML.Security.asp` | XSS escaping (`H()` function) | +| `lib.HTML.Security.asp` | XSS escaping — `H()` function | | `lib.Strings.asp` | String utilities | -| `lib.Automapper.asp` | Object mapping helpers | +| `lib.Automapper.asp` | `Automapper.AutoMap(rs, "POBO_TableName")` | | `lib.ErrorHandler.asp` | Error handling | | `lib.Migrations.asp` | Migration runner | | `lib.json.asp` | JSON parsing/serialization | @@ -64,40 +65,340 @@ scripts/ VBScript code generators --- -## Routes +## Global helper functions (core/helpers.asp) + +These are always available — never re-implement them. + +| Function | Signature | Purpose | +|---|---|---| +| `H` | `H(s)` | XSS-safe HTML encode — use on all user data rendered to HTML | +| `GenerateSlug` | `GenerateSlug(title)` | Converts a title to a safe URL slug (lowercase, hyphens, alphanumeric only) | +| `GetRawJsonFromRequest` | `GetRawJsonFromRequest()` | Reads raw JSON body from an AJAX POST request | +| `GetAppSetting` | `GetAppSetting(key)` | Reads a value from `web.config` appSettings (cached in `Application`) | +| `IIf` | `IIf(condition, trueVal, falseVal)` | Inline conditional | +| `TrimQueryParams` | `TrimQueryParams(path)` | Strips query string from a URL path | +| `Destroy` | `Destroy(obj)` | Safely closes and sets an object to Nothing | +| `QuoteValue` | `QuoteValue(val)` | Quotes a value for SQL (prefer parameterized queries via DAL instead) | +| `FormatDateForSql` | `FormatDateForSql(date)` | Formats a VBScript date as a SQL Server datetime string | +| `Active` | `Active(controllerName)` | Returns `"active"` if the current request maps to that controller | +| `SurroundString` | `SurroundString(val)` | Wraps strings in double-quotes (used internally by dispatcher) | +| `GetDynamicProperty` | `GetDynamicProperty(obj, propName)` | Reads a property by name from any VBScript object | +| `RenderObjectsAsTable` | `RenderObjectsAsTable(arr, useTabulator)` | Generates an HTML table from an array of POBOs | +| `RenderFormFromObject` | `RenderFormFromObject(obj)` | Generates an HTML form from a POBO | -Defined in [public/Default.asp](public/Default.asp): +--- + +## Router — URL parameters + +Routes are registered in [public/Default.asp](public/Default.asp) and support `:param` segments. + +**Registering routes:** +```vbscript +' Static route +router.AddRoute "GET", "/boards", "BoardsController", "Index" +router.AddRoute "POST", "/boards", "BoardsController", "Store" -| Method | Path | Controller | Action | -|---|---|---|---| -| GET | `/` | HomeController | Index | -| GET | `/home` | HomeController | Index | -| GET | `/auth/login` | AuthController | Login | -| GET | `/auth/callback` | AuthController | Callback | -| GET | `/auth/logout` | AuthController | Logout | -| GET | `/404` | ErrorController | NotFound | +' Route with one URL parameter +router.AddRoute "GET", "/board/:slug", "BoardsController", "Show" +router.AddRoute "POST", "/board/:slug", "BoardsController", "Update" + +' Route with one URL parameter and a sub-action +router.AddRoute "POST", "/cards/:id/move", "CardsController", "Move" +router.AddRoute "POST", "/cards/:id/delete", "CardsController", "Destroy" +``` + +**Controller action receives parameters as arguments in order:** +```vbscript +' Matches /board/:slug +Public Sub Show(slug) + ' slug is the extracted URL segment +End Sub + +' Matches /cards/:id/move +Public Sub Move(id) + ' id is the extracted URL segment +End Sub +``` + +The router does an **exact match first**, then falls back to `:param` pattern matching. If nothing matches it returns `ErrorController#NotFound`. + +**URL generation from controllers/views:** +```vbscript +' /boards +Routes.UrlTo "Boards", "Index", Empty + +' /board/my-board (route param appended as positional segment) +Routes.UrlToWithParams "Boards", "Show", Array("my-board"), Empty + +' With query string +Routes.UrlTo "Boards", "Index", Array("page", 2) +``` All requests are rewritten through `Default.asp` by the IIS URL Rewrite rule. Static assets (`css/`, `js/`, `images/`, `favicon.ico`) bypass the rewrite. --- -## Configuration (public/web.config appSettings) +## POBO pattern (app/models/) -| Key | Description | -|---|---| -| `ConnectionString` | ACE OLEDB path to `webdata.accdb` | -| `Environment` | `Development`, `Staging`, or `Production` | -| `KeycloakBaseUrl` | Keycloak server base URL (no `/realms/...`) | -| `KeycloakRealm` | Keycloak realm name | -| `KeycloakClientId` | Client ID | -| `KeycloakClientSecret` | Client secret — never commit real values | -| `KeycloakRedirectUri` | Absolute callback URL, e.g. `http://localhost:8080/auth/callback` | -| `KeycloakLogoutRedirectUri` | Post-logout redirect URL | -| `KeycloakScope` | OIDC scopes (default: `openid profile email`) | -| `KeycloakEnableLogging` | `true`/`false` — diagnostic log for auth failures | -| `KeycloakLogPath` | Path to Keycloak log file | -| `EnableErrorLogging` | `true`/`false` | -| `ErrorLogPath` | Path to error log file | +Plain-Old Business Object — one class per table. Generated by `scripts/GenerateRepo.vbs`. + +```vbscript +<% +Class POBO_boards + Public Properties ' array of all column names + + Private p_id + Private p_name + Private p_slug + Private p_created_at + Private p_created_by + Private p_updated_at + Private p_updated_by + + Private Sub Class_Initialize() + p_id = 0 + p_name = "" + p_slug = "" + p_created_at = #1/1/1970# + p_created_by = "" + p_updated_at = #1/1/1970# + p_updated_by = "" + Properties = Array("id","name","slug","created_at","created_by","updated_at","updated_by") + End Sub + + Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property + Public Property Get TableName() : TableName = "boards" : End Property + + Public Property Get id() : id = p_id : End Property + Public Property Let id(v) : p_id = CDbl(v) : End Property + + Public Property Get name() : name = p_name : End Property + Public Property Let name(v) : p_name = CStr(v) : End Property + + ' ... remaining Get/Let pairs follow same pattern +End Class +%> +``` + +Key rules: +- Private backing fields use the `p` prefix: `p_id`, `p_name`, etc. +- `Properties` array lists all column names — required by `Automapper` and `RenderObjectsAsTable`. +- `PrimaryKey` and `TableName` must match the actual table. + +--- + +## Repository pattern (app/repositories/) + +Generated by `scripts/GenerateRepo.vbs`. Uses the `DAL` singleton and `Automapper`. + +```vbscript +<% +Class boards_Repository_Class + + ' Single record by PK + Public Function FindByID(id) + Dim sql : sql = "SELECT [id],[name],[slug],[created_at],[created_by],[updated_at],[updated_by] FROM [boards] WHERE [id] = ?" + Dim rs : Set rs = DAL.Query(sql, Array(id)) + If rs.EOF Then + Err.Raise 1, "boards_Repository_Class", "boards record not found with id = " & id + Else + Set FindByID = Automapper.AutoMap(rs, "POBO_boards") + End If + Destroy rs + End Function + + ' All records, optional order + Public Function GetAll(orderBy) + Set GetAll = Find(Empty, orderBy) + End Function + + ' Filtered list — where_kvarray is a flat key/value array: Array("col", val, "col2", val2) + Public Function Find(where_kvarray, order_string_or_array) + Dim sql : sql = "SELECT ... FROM [boards]" + Dim where_keys, where_values, i + If Not IsEmpty(where_kvarray) Then + KVUnzip where_kvarray, where_keys, where_values + ' appends WHERE clause for each key + End If + sql = sql & BuildOrderBy(order_string_or_array, "[id]") + Dim rs : Set rs = DAL.Query(sql, where_values) + Dim list : Set list = New LinkedList_Class + Do Until rs.EOF + list.Push Automapper.AutoMap(rs, "POBO_boards") + rs.MoveNext + Loop + Set Find = list + Destroy rs + End Function + + ' Insert — sets model.id to the new identity value + Public Sub AddNew(ByRef model) + Dim sql : sql = "INSERT INTO [boards] ([name],[slug],...) VALUES (?,?,...)" + DAL.Execute sql, Array(model.name, model.slug, ...) + Dim rsId : Set rsId = DAL.Query("SELECT @@IDENTITY AS NewID", Empty) + If Not rsId.EOF Then + If Not IsNull(rsId(0)) Then model.id = rsId(0) + End If + Destroy rsId + End Sub + + ' Update — all columns except PK, PK appended last for WHERE + Public Sub Update(model) + Dim sql : sql = "UPDATE [boards] SET [name]=?,[slug]=?,... WHERE [id]=?" + DAL.Execute sql, Array(model.name, model.slug, ..., model.id) + End Sub + + ' Delete by PK + Public Sub Delete(id) + DAL.Execute "DELETE FROM [boards] WHERE [id]=?", Array(id) + End Sub + +End Class + +Dim boards_Repository__Singleton +Function boards_Repository() + If IsEmpty(boards_Repository__Singleton) Then + Set boards_Repository__Singleton = New boards_Repository_Class + End If + Set boards_Repository = boards_Repository__Singleton +End Function +%> +``` + +--- + +## Controller pattern (app/controllers/) + +Generated by `scripts/generateController.vbs`. Singleton per request. + +```vbscript +<% +Class BoardsController_Class + Private m_useLayout + Private m_title + + Private Sub Class_Initialize() + m_useLayout = True + m_title = "Boards" + End Sub + + Public Property Get useLayout() : useLayout = m_useLayout : End Property + Public Property Let useLayout(v) : m_useLayout = v : End Property + Public Property Get Title() : Title = m_title : End Property + Public Property Let Title(v) : m_title = v : End Property + + ' GET /boards + Public Sub Index() + If Not KeycloakRequireLogin("") Then Exit Sub + ' ... load data, render view + End Sub + + ' GET /board/:slug — slug comes from the URL parameter + Public Sub Show(slug) + If Not KeycloakRequireLogin("") Then Exit Sub + ' ... load board by slug, render view + End Sub + + ' POST /boards — reads from Request.Form + Public Sub Store() + If Not KeycloakRequireLogin("") Then Exit Sub + ' ... validate, save, redirect + End Sub + + ' JSON endpoint — set useLayout = False, write JSON response + Public Sub Move(id) + m_useLayout = False + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + ' ... process, write JSON + End Sub + +End Class + +Dim BoardsController_Class__Singleton +Function BoardsController() + If IsEmpty(BoardsController_Class__Singleton) Then + Set BoardsController_Class__Singleton = New BoardsController_Class + End If + Set BoardsController = BoardsController_Class__Singleton +End Function +%> +``` + +**JSON / AJAX actions** must set `m_useLayout = False` and `Response.ContentType = "application/json"`. + +**Reading a JSON request body** (sent by `fetch` / XHR): +```vbscript +Dim rawJson : rawJson = GetRawJsonFromRequest() +' then parse with lib.json.asp +``` + +--- + +## Audit columns + +**Every table must include these four audit columns:** + +| Column | Type | Populated by | +|---|---|---| +| `created_at` | DateTime | Set once on insert: `Now()` | +| `created_by` | Text(255) | Set once on insert: `preferred_username` from Keycloak | +| `updated_at` | DateTime | Updated on every save: `Now()` | +| `updated_by` | Text(255) | Updated on every save: `preferred_username` from Keycloak | + +**Getting the current username in a controller action:** +```vbscript +Dim currentUser : Set currentUser = KeycloakCurrentUser() +Dim currentUsername : currentUsername = "" +If Not currentUser Is Nothing Then + currentUsername = currentUser.Item("preferred_username") +End If +``` + +**Setting audit fields before insert:** +```vbscript +model.created_at = Now() +model.created_by = currentUsername +model.updated_at = Now() +model.updated_by = currentUsername +repo.AddNew model +``` + +**Setting audit fields before update:** +```vbscript +model.updated_at = Now() +model.updated_by = currentUsername +repo.Update model +``` + +`created_at` and `created_by` are **never changed** after the initial insert. + +--- + +## Slug generation + +`GenerateSlug(title)` is already in `core/helpers.asp`. Always use it — never write your own slug logic. + +```vbscript +Dim slug : slug = GenerateSlug(Request.Form("name")) +' "My Board & Things!" → "my-board-and-things" +``` + +After generating, check uniqueness against the database before saving. If the slug already exists, append a suffix (e.g., `-2`, `-3`). + +--- + +## Wiring up a new controller — checklist + +1. Generate: `cscript //nologo scripts\generateController.vbs MyController "Index;Show(slug);Store"` +2. Move generated file to `app/controllers/` +3. Register in [core/lib.ControllerRegistry.asp](core/lib.ControllerRegistry.asp): `RegisterController "mycontroller"` +4. Include in [app/controllers/autoload_controllers.asp](app/controllers/autoload_controllers.asp): `` +5. Add routes in [public/Default.asp](public/Default.asp) +6. Create views in `app/views/MyController/` --- @@ -108,50 +409,42 @@ The helper in `core/lib.Keycloak.asp` implements the OpenID Connect authorizatio **Key functions:** ```vbscript -KeycloakLogin() ' Redirect to Keycloak -KeycloakHandleCallback() ' Exchange code, store tokens — returns True on success -KeycloakIsLoggedIn() ' True if access token is in Session -KeycloakCurrentUser() ' Returns userinfo dictionary -KeycloakAccessToken() ' Raw access token string +KeycloakRequireLogin("") ' Gate full-page actions — redirects if not logged in; returns False to signal early exit +KeycloakIsLoggedIn() ' Use this for JSON/AJAX actions instead of RequireLogin +KeycloakCurrentUser() ' Returns userinfo Dictionary — keys include preferred_username, email, name, sub +KeycloakLogin() ' Redirect to Keycloak +KeycloakHandleCallback() ' Exchange code, store tokens — returns True on success +KeycloakAccessToken() ' Raw access token string KeycloakRefreshToken() KeycloakIdToken() -KeycloakTokenClaims(token) ' Decode JWT payload into dictionary -KeycloakRequireLogin(returnToPath) ' Gate a page — redirects if not logged in -KeycloakConsumePostLoginRedirectPath("/") ' Get and clear stored return path -KeycloakHasRealmRole("admin") ' Role check against ID token +KeycloakTokenClaims(token) ' Decode JWT payload into dictionary +KeycloakConsumePostLoginRedirectPath("/") ' Get and clear stored return path +KeycloakHasRealmRole("admin") ' Role check against ID token KeycloakHasClientRole(clientId, role) -KeycloakLogout("") ' Clear session and redirect to Keycloak logout +KeycloakLogout("") ' Clear session and redirect to Keycloak logout ``` Session keys use the `Keycloak_` prefix. Tokens are stored in `Session`, not cookies. --- -## Adding a new feature — step by step - -### 1. Migration -```bat -cscript //nologo scripts\generateMigration.vbs create_my_table -cscript //nologo scripts\runMigrations.vbs -``` - -### 2. Model and repository -```bat -cscript //nologo scripts\GenerateRepo.vbs /table:my_table /pk:id -``` -Move generated files to `app/models/` and `app/repositories/`. - -### 3. Controller -```bat -cscript //nologo scripts\generateController.vbs MyController "Index;Show(id);Create;Store" -``` -Move generated file to `app/controllers/`. +## Configuration (public/web.config appSettings) -### 4. Wire it up -1. Register in [core/lib.ControllerRegistry.asp](core/lib.ControllerRegistry.asp): `RegisterController "mycontroller"` -2. Include in [app/controllers/autoload_controllers.asp](app/controllers/autoload_controllers.asp) -3. Add routes in [public/Default.asp](public/Default.asp) -4. Create views in `app/views/MyController/` +| Key | Description | +|---|---| +| `ConnectionString` | ACE OLEDB path to `webdata.accdb` | +| `Environment` | `Development`, `Staging`, or `Production` | +| `KeycloakBaseUrl` | Keycloak server base URL (no `/realms/...`) | +| `KeycloakRealm` | Keycloak realm name | +| `KeycloakClientId` | Client ID | +| `KeycloakClientSecret` | Client secret — never commit real values | +| `KeycloakRedirectUri` | Absolute callback URL | +| `KeycloakLogoutRedirectUri` | Post-logout redirect URL | +| `KeycloakScope` | OIDC scopes (default: `openid profile email`) | +| `KeycloakEnableLogging` | `true`/`false` | +| `KeycloakLogPath` | Path to Keycloak log file | +| `EnableErrorLogging` | `true`/`false` | +| `ErrorLogPath` | Path to error log file | --- @@ -164,23 +457,13 @@ Tests live in `tests/` and run as a **separate IIS application** (never through - Manifest: `tests/test-manifest.asp` — register new test pages here manually - Bootstrap: `tests/bootstrap.asp` — shared setup for all test pages -Test tiers: - | Folder | Use for | |---|---| | `tests/unit/` | Deterministic helper and registry tests | | `tests/component/` | Controlled controller/object tests | | `tests/integration/` | Router/dispatch smoke tests, config behavior, rendered-page capture | -After changing `tests/web.config`, sync nested configs: -```bat -cscript //nologo tests\sync-webconfigs.vbs -``` - -Or sync and open in one step: -```bat -tests\run-tests.cmd -``` +After changing `tests/web.config`: `cscript //nologo tests\sync-webconfigs.vbs` --- @@ -194,6 +477,75 @@ tests\run-tests.cmd --- +## LinkedList_Class — correct traversal + +`LinkedList_Class` (from `core/lib.Collections.asp`) does **not** have public `.First` or `.Next` properties — `m_first` and `m_last` are private fields. Calling `.First` on a list throws Error 438. + +**Always use the `Iterator()` pattern:** +```vbscript +Dim iter : Set iter = myList.Iterator() +Do While iter.HasNext() + Set item = iter.GetNext() ' returns the value directly — NOT a node wrapper + ' use item +Loop +``` + +`GetNext()` returns the value object directly, so there is no `.m_value` or `.m_next` to dereference. + +**Other valid approaches:** +```vbscript +' Convert to array for indexed access +Dim arr : arr = myList.TO_Array() +For i = 0 To UBound(arr) + Set item = arr(i) +Next + +' Get single values at the ends +myList.Front() ' returns first value +myList.Back() ' returns last value +myList.Count ' number of elements +myList.IsEmpty() ' Boolean +``` + +Never use `.First`, `.Last`, `.m_first`, `.m_next`, or `.m_value` on a list — those are internal node fields, not a public API. + +--- + +## AJAX form data — always use URLSearchParams, never FormData + +Classic ASP's `Request.Form` collection **only parses `application/x-www-form-urlencoded`** POST bodies. Using `new FormData()` in JavaScript sends `multipart/form-data`, which Classic ASP silently ignores — every field comes back as an empty string. + +**Always use `URLSearchParams` for fetch POST requests that the server reads via `Request.Form`:** +```javascript +function post(url, data, cb) { + var params = new URLSearchParams(); + Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString() + }) + .then(function (r) { return r.json(); }) + .then(cb) + .catch(function (e) { console.error(url, e); }); +} +``` + +**`Request.BinaryRead` vs `Request.Form` mutual exclusion:** Once you call `Request.BinaryRead()` (which `GetRawJsonFromRequest()` does internally), `Request.Form` becomes unavailable for that same request, and vice versa. Never mix the two in the same action. Use `GetRawJsonFromRequest()` only in actions that receive a raw JSON body (e.g. `Reorder`), and use `Request.Form` only in actions that receive URL-encoded form data (e.g. `Store`, `Update`, `Move`). + +--- + +## Critical Classic ASP scoping rule for views + +Controller actions include view files via SSI (``). The included file's content compiles **inside** the controller action's Sub/Function scope. This means: + +- **Never define `Function` or `Sub` inside a view file** — VBScript forbids nested procedure definitions. You will get `Syntax error 800a03ea`. +- All `Dim`, `ReDim`, loops, and conditionals in a view are fine — only `Function`/`Sub` declarations are forbidden. +- If a view needs a helper function, define it as a `Private Function` in the controller class, build the result in the action, and pass it as a variable the view can reference. +- Keep views to pure HTML rendering: `<%= variable %>`, `H(value)`, simple `<% If / For / Do %>` blocks, and includes of other partials. + +--- + ## Things to avoid - Do not modify files under `core/` — these are framework internals. @@ -201,4 +553,31 @@ tests\run-tests.cmd - Do not commit real `KeycloakClientSecret` values — inject per environment. - Do not add test routes or test pages under `public/`. - Do not use the production `public/` IIS app to run tests. -- Always use `H()` from `lib.HTML.Security.asp` when rendering user-supplied data to prevent XSS. +- Always use `H()` when rendering user-supplied data to prevent XSS. +- Never write your own slug generator — use `GenerateSlug()` from `helpers.asp`. +- Never use `Private Const` or `Public Const` inside a VBScript class — it causes a syntax error. Use a `Private Function` that returns the value instead. +- In Access/Jet SQL DDL, common reserved words include `COLUMNS`, `NAME`, `POSITION`, `VALUE`, `DATE`, `KEY`, `LEVEL`, `BY`. **Always use `migration.ExecuteSQL` and bracket every identifier** — both table names and column names — e.g. `CREATE TABLE [board_columns] ([id] AUTOINCREMENT PRIMARY KEY, [name] VARCHAR(255), [position] INTEGER, ...)`. Never use `migration.CreateTable` or `migration.CreateIndex`; use `migration.ExecuteSQL` for all DDL. +- Never read raw JSON bodies manually — use `GetRawJsonFromRequest()` from `helpers.asp`. +- Never use `new FormData()` in JavaScript for requests that Classic ASP reads via `Request.Form` — it sends `multipart/form-data` which Classic ASP silently ignores. Use `URLSearchParams` instead. +- Never mix `Request.BinaryRead` / `GetRawJsonFromRequest` with `Request.Form` in the same action — they are mutually exclusive in Classic ASP. +- Never call `.First`, `.Last`, `.m_first`, `.m_next`, or `.m_value` on a `LinkedList_Class` instance — those are private internals. Use `.Iterator()` / `HasNext()` / `GetNext()` or `.TO_Array()`. +- Always set all four audit columns (`created_at`, `created_by`, `updated_at`, `updated_by`) on every insert and update. +- For JSON/AJAX actions, always set `m_useLayout = False` and `Response.ContentType = "application/json"` at the top of the action. + +--- + +## Recent lessons (2026-04-22) + +- IIS/runtime bitness matters for Access on this machine. Use 32-bit IIS Express (`%ProgramFiles(x86)%\IIS Express\iisexpress.exe`) and keep `enable32BitAppOnWin64="true"` in the active app pool. +- Run migrations with 32-bit cscript: + - `C:\Windows\SysWOW64\cscript.exe //nologo scripts\runMigrations.vbs up` +- Standalone migration context (`scripts/runMigrations.vbs`) is not identical to IIS runtime migration context. `migration.DB.Execute/Query` may fail there; migrations should support fallback via `migration.Connection` + `ADODB.Command`. +- `Flash_Class` API is `AddError` and `Success` property assignment. `SetError` / `SetSuccess` are invalid and cause `438`. +- `KeycloakCurrentUser()` should be read defensively. Do not assume `.Exists(...)` is always available; use guarded `.Item("preferred_username")` / `.Item("email")` reads. +- Shared navbar should include direct `/boards` navigation and use `Active("boards")` for active state. + +## Additions to Things to avoid + +- Never run `scripts\runMigrations.vbs` with 64-bit cscript on this machine; use `C:\Windows\SysWOW64\cscript.exe`. +- Never assume migration helper parity between IIS and standalone runner; if `migration.DB.Execute/Query` are used, provide a `migration.Connection` fallback path. +- Never call `Flash().SetError` or `Flash().SetSuccess`; use `Flash().AddError` and `flash.Success = "..."`. diff --git a/app/controllers/BoardsController.asp b/app/controllers/BoardsController.asp new file mode 100644 index 0000000..99076bb --- /dev/null +++ b/app/controllers/BoardsController.asp @@ -0,0 +1,265 @@ +<% +Class BoardsController_Class + Private m_useLayout + Private m_title + + Private Sub Class_Initialize() + m_useLayout = True + m_title = "Boards" + End Sub + + Public Property Get useLayout() : useLayout = m_useLayout : End Property + Public Property Let useLayout(v) : m_useLayout = v : End Property + Public Property Get Title() : Title = m_title : End Property + Public Property Let Title(v) : m_title = v : End Property + + ' GET /boards + Public Sub Index() + If Not KeycloakRequireLogin("") Then Exit Sub + Dim boards : Set boards = boards_Repository().GetAll() +%> + +<% + Set boards = Nothing + End Sub + + ' GET /boards/create + Public Sub Create() + If Not KeycloakRequireLogin("") Then Exit Sub + m_title = "New Board" +%> + +<% + End Sub + + ' POST /boards + Public Sub Store() + If Not KeycloakRequireLogin("") Then Exit Sub + + Dim boardName, slug, currentUsername + boardName = Trim(CStr(Request.Form("name"))) + + If Len(boardName) = 0 Then + Dim flashCreateErr : Set flashCreateErr = Flash() + flashCreateErr.AddError "Board name is required." + MVC.RedirectTo "Boards", "Create" + Exit Sub + End If + + currentUsername = GetCurrentUsername() + slug = boards_Repository().UniqueSlug(GenerateSlug(boardName), 0) + + Dim board : Set board = New POBO_boards + board.name = boardName + board.slug = slug + board.import_from_printstream = (Request.Form("import_from_printstream") = "on") + board.printstream_job_name = Trim(CStr(Request.Form("printstream_job_name"))) + board.created_at = Now() + board.created_by = currentUsername + board.updated_at = Now() + board.updated_by = currentUsername + + boards_Repository().AddNew board + + Dim flashCreateOk : Set flashCreateOk = Flash() + flashCreateOk.Success = "Board created." + Response.Redirect "/board/" & board.slug + End Sub + + ' GET /board/:slug + Public Sub Show(slug) + If Not KeycloakRequireLogin("") Then Exit Sub + m_useLayout = False + + Dim board : Set board = boards_Repository().FindBySlug(slug) + If board Is Nothing Then + Response.Status = "404 Not Found" + Response.Write "Board not found." + Exit Sub + End If + + m_title = board.name + + Dim columns : Set columns = board_columns_Repository().FindByBoardId(board.id) + Dim lanes : Set lanes = swim_lanes_Repository().FindByBoardId(board.id) + Dim allCards : Set allCards = cards_Repository().FindByBoardId(board.id) + + ' Build arrays for the view (functions cannot be defined inside a view include) + Dim colCount : colCount = columns.Count + Dim laneCount : laneCount = lanes.Count + + Dim colsArr() : ReDim colsArr(IIf(colCount > 0, colCount - 1, 0)) + Dim lanesArr() : ReDim lanesArr(IIf(laneCount > 0, laneCount - 1, 0)) + + Dim colIdx, laneIdx, colIter, laneIter, colItem, laneItem + colIdx = 0 + Set colIter = columns.Iterator() + Do While colIter.HasNext() + Set colsArr(colIdx) = colIter.GetNext() + colIdx = colIdx + 1 + Loop + + laneIdx = 0 + Set laneIter = lanes.Iterator() + Do While laneIter.HasNext() + Set lanesArr(laneIdx) = laneIter.GetNext() + laneIdx = laneIdx + 1 + Loop + + ' Serialise cards to JSON + Dim cardsJson : cardsJson = "[" + Dim firstCard : firstCard = True + Dim cardIter, cardItem + Set cardIter = allCards.Iterator() + Do While cardIter.HasNext() + Set cardItem = cardIter.GetNext() + If Not firstCard Then cardsJson = cardsJson & "," + cardsJson = cardsJson & "{""id"":" & cardItem.id & "," & _ + """column_id"":" & cardItem.column_id & "," & _ + """swim_lane_id"":" & cardItem.swim_lane_id & "," & _ + """job_number"":" & JsonStr(cardItem.job_number) & "," & _ + """job_name"":" & JsonStr(cardItem.job_name) & "," & _ + """customer_name"":" & JsonStr(cardItem.customer_name) & "," & _ + """delivery_date"":" & JsonDateStr(cardItem.delivery_date) & "," & _ + """quantity"":" & JsonStr(cardItem.quantity) & "," & _ + """notes"":" & JsonStr(cardItem.notes) & "," & _ + """full_note"":" & JsonStr(cardItem.full_note) & "," & _ + """position"":" & cardItem.position & "}" + firstCard = False + Loop + cardsJson = cardsJson & "]" +%> + +<% + Set board = Nothing + Set columns = Nothing + Set lanes = Nothing + Set allCards = Nothing + End Sub + + ' GET /board/:slug/edit + Public Sub Edit(slug) + If Not KeycloakRequireLogin("") Then Exit Sub + m_title = "Edit Board" + + Dim board : Set board = boards_Repository().FindBySlug(slug) + If board Is Nothing Then + Response.Status = "404 Not Found" + Response.Write "Board not found." + Exit Sub + End If +%> + +<% + Set board = Nothing + End Sub + + ' POST /board/:slug/update + Public Sub Update(slug) + If Not KeycloakRequireLogin("") Then Exit Sub + + Dim board : Set board = boards_Repository().FindBySlug(slug) + If board Is Nothing Then + Response.Status = "404 Not Found" + Response.Write "Board not found." + Exit Sub + End If + + Dim newName : newName = Trim(CStr(Request.Form("name"))) + If Len(newName) = 0 Then + Dim flashUpdateErr : Set flashUpdateErr = Flash() + flashUpdateErr.AddError "Board name is required." + Response.Redirect "/board/" & slug & "/edit" + Exit Sub + End If + + Dim newSlug : newSlug = boards_Repository().UniqueSlug(GenerateSlug(newName), CLng(board.id)) + + board.name = newName + board.slug = newSlug + board.import_from_printstream = (Request.Form("import_from_printstream") = "on") + board.printstream_job_name = Trim(CStr(Request.Form("printstream_job_name"))) + board.updated_at = Now() + board.updated_by = GetCurrentUsername() + + boards_Repository().Update board + + Dim flashUpdateOk : Set flashUpdateOk = Flash() + flashUpdateOk.Success = "Board updated." + Response.Redirect "/board/" & newSlug + End Sub + + ' POST /board/:slug/delete + Public Sub Destroy(slug) + If Not KeycloakRequireLogin("") Then Exit Sub + + Dim board : Set board = boards_Repository().FindBySlug(slug) + If board Is Nothing Then + Response.Redirect "/boards" + Exit Sub + End If + + Dim boardId : boardId = CLng(board.id) + cards_Repository().DeleteByBoardId boardId + board_columns_Repository().DeleteByBoardId boardId + swim_lanes_Repository().DeleteByBoardId boardId + boards_Repository().Delete boardId + + Dim flashDeleteOk : Set flashDeleteOk = Flash() + flashDeleteOk.Success = "Board deleted." + MVC.RedirectTo "Boards", "Index" + End Sub + + Private Function GetCurrentUsername() + Dim u : Set u = KeycloakCurrentUser() + Dim name : name = "" + If Not u Is Nothing Then + On Error Resume Next + name = CStr(u.Item("preferred_username")) + If Err.Number <> 0 Then + name = "" + Err.Clear + End If + On Error GoTo 0 + End If + GetCurrentUsername = name + End Function + + Private Function JsonStr(s) + Dim v : v = CStr(s) + v = Replace(v, "\", "\\") + v = Replace(v, """", "\""") + v = Replace(v, vbCrLf, "\n") + v = Replace(v, vbLf, "\n") + v = Replace(v, vbCr, "\n") + JsonStr = """" & v & """" + End Function + + Private Function JsonDateStr(d) + If IsNull(d) Or IsEmpty(d) Then + JsonDateStr = "null" + Exit Function + End If + On Error Resume Next + Dim dt, s + dt = CDate(d) + s = Year(dt) & "-" & Right("0" & Month(dt), 2) & "-" & Right("0" & Day(dt), 2) + If Err.Number <> 0 Then + Err.Clear + JsonDateStr = "null" + Else + JsonDateStr = """" & s & """" + End If + On Error GoTo 0 + End Function + +End Class + +Dim BoardsController_Class__Singleton +Function BoardsController() + If IsEmpty(BoardsController_Class__Singleton) Then + Set BoardsController_Class__Singleton = New BoardsController_Class + End If + Set BoardsController = BoardsController_Class__Singleton +End Function +%> diff --git a/app/controllers/CardsController.asp b/app/controllers/CardsController.asp new file mode 100644 index 0000000..ead4d08 --- /dev/null +++ b/app/controllers/CardsController.asp @@ -0,0 +1,220 @@ +<% +Class CardsController_Class + Private m_useLayout + Private m_title + + Private Sub Class_Initialize() + m_useLayout = False + m_title = "Cards" + End Sub + + Public Property Get useLayout() : useLayout = m_useLayout : End Property + Public Property Let useLayout(v) : m_useLayout = v : End Property + Public Property Get Title() : Title = m_title : End Property + Public Property Let Title(v) : m_title = v : End Property + + ' POST /cards + Public Sub Store() + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + + Dim boardId, columnId, swimLaneId, jobNum, jobName + boardId = CLng(Request.Form("board_id")) + columnId = CLng(Request.Form("column_id")) + swimLaneId = CLng(Request.Form("swim_lane_id")) + jobNum = Trim(CStr(Request.Form("job_number"))) + jobName = Trim(CStr(Request.Form("job_name"))) + + If boardId = 0 Or columnId = 0 Or swimLaneId = 0 Then + Response.Write "{""ok"":false,""error"":""board_id, column_id, and swim_lane_id are required""}" + Exit Sub + End If + + Dim nextPos : nextPos = cards_Repository().MaxPosition(columnId, swimLaneId) + 1 + Dim username : username = GetCurrentUsername() + + Dim card : Set card = New POBO_cards + card.board_id = boardId + card.column_id = columnId + card.swim_lane_id = swimLaneId + card.job_number = jobNum + card.job_name = jobName + card.customer_name = Trim(CStr(Request.Form("customer_name") & "")) + card.delivery_date = Trim(CStr(Request.Form("delivery_date") & "")) + card.quantity = Trim(CStr(Request.Form("quantity") & "")) + card.notes = Trim(CStr(Request.Form("notes") & "")) + card.full_note = CStr(Request.Form("full_note") & "") + card.position = nextPos + card.created_at = Now() + card.created_by = username + card.updated_at = Now() + card.updated_by = username + + On Error Resume Next + cards_Repository().AddNew card + If Err.Number <> 0 Then + Response.Write "{""ok"":false,""error"":" & JsonString(Err.Description) & "}" + Err.Clear + Exit Sub + End If + On Error GoTo 0 + + Response.Write "{""ok"":true,""id"":" & card.id & "," & _ + """job_number"":" & JsonString(card.job_number) & "," & _ + """job_name"":" & JsonString(card.job_name) & "," & _ + """customer_name"":" & JsonString(card.customer_name) & "," & _ + """delivery_date"":" & JsonDateStr(card.delivery_date) & "," & _ + """quantity"":" & JsonString(card.quantity) & "," & _ + """notes"":" & JsonString(card.notes) & "," & _ + """full_note"":" & JsonString(card.full_note) & "," & _ + """column_id"":" & card.column_id & "," & _ + """swim_lane_id"":" & card.swim_lane_id & "," & _ + """position"":" & card.position & "}" + End Sub + + ' POST /cards/:id + Public Sub Update(id) + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + + On Error Resume Next + Dim card : Set card = cards_Repository().FindByID(CLng(id)) + If Err.Number <> 0 Then + Err.Clear + Response.Write "{""ok"":false,""error"":""Not found""}" + Exit Sub + End If + On Error GoTo 0 + + card.job_number = Trim(CStr(Request.Form("job_number"))) + card.job_name = Trim(CStr(Request.Form("job_name"))) + card.customer_name = Trim(CStr(Request.Form("customer_name") & "")) + card.delivery_date = Trim(CStr(Request.Form("delivery_date") & "")) + card.quantity = Trim(CStr(Request.Form("quantity") & "")) + card.notes = Trim(CStr(Request.Form("notes") & "")) + card.full_note = CStr(Request.Form("full_note") & "") + card.updated_at = Now() + card.updated_by = GetCurrentUsername() + + On Error Resume Next + cards_Repository().Update card + If Err.Number <> 0 Then + Response.Write "{""ok"":false,""error"":" & JsonString(Err.Description) & "}" + Err.Clear + Exit Sub + End If + On Error GoTo 0 + + Response.Write "{""ok"":true," & _ + """job_number"":" & JsonString(card.job_number) & "," & _ + """job_name"":" & JsonString(card.job_name) & "," & _ + """customer_name"":" & JsonString(card.customer_name) & "," & _ + """delivery_date"":" & JsonDateStr(card.delivery_date) & "," & _ + """quantity"":" & JsonString(card.quantity) & "," & _ + """notes"":" & JsonString(card.notes) & "," & _ + """full_note"":" & JsonString(card.full_note) & "}" + End Sub + + ' POST /cards/:id/move — form: column_id, swim_lane_id, position, [sibling_ids CSV for reorder] + Public Sub Move(id) + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + + Dim columnId, swimLaneId, position + columnId = CLng(Request.Form("column_id")) + swimLaneId = CLng(Request.Form("swim_lane_id")) + position = CLng(Request.Form("position")) + + Dim username : username = GetCurrentUsername() + cards_Repository().Move CLng(id), columnId, swimLaneId, position, Now(), username + + ' Reorder siblings if provided + Dim siblings : siblings = Trim(CStr(Request.Form("sibling_ids"))) + If Len(siblings) > 0 Then + Dim ids, idx + ids = Split(siblings, ",") + For idx = 0 To UBound(ids) + Dim sibId : sibId = CLng(Trim(ids(idx))) + If sibId > 0 Then + cards_Repository().UpdatePosition sibId, idx, Now(), username + End If + Next + End If + + Response.Write "{""ok"":true}" + End Sub + + ' POST /cards/:id/delete + Public Sub Destroy(id) + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + + cards_Repository().Delete CLng(id) + Response.Write "{""ok"":true}" + End Sub + + Private Function GetCurrentUsername() + Dim u : Set u = KeycloakCurrentUser() + Dim name : name = "" + If Not u Is Nothing Then + On Error Resume Next + name = CStr(u.Item("preferred_username")) + If Err.Number <> 0 Then + name = "" + Err.Clear + End If + On Error GoTo 0 + End If + GetCurrentUsername = name + End Function + + Private Function JsonString(s) + Dim v : v = CStr(s & "") + v = Replace(v, "\", "\\") + v = Replace(v, """", "\""") + v = Replace(v, vbCrLf, "\n") + v = Replace(v, vbLf, "\n") + v = Replace(v, vbCr, "\n") + JsonString = """" & v & """" + End Function + + Private Function JsonDateStr(d) + If IsNull(d) Or IsEmpty(d) Then + JsonDateStr = "null" + Exit Function + End If + On Error Resume Next + Dim dt, s + dt = CDate(d) + s = Year(dt) & "-" & Right("0" & Month(dt), 2) & "-" & Right("0" & Day(dt), 2) + If Err.Number <> 0 Then + Err.Clear + JsonDateStr = "null" + Else + JsonDateStr = """" & s & """" + End If + On Error GoTo 0 + End Function + +End Class + +Dim CardsController_Class__Singleton +Function CardsController() + If IsEmpty(CardsController_Class__Singleton) Then + Set CardsController_Class__Singleton = New CardsController_Class + End If + Set CardsController = CardsController_Class__Singleton +End Function +%> diff --git a/app/controllers/ColumnsController.asp b/app/controllers/ColumnsController.asp new file mode 100644 index 0000000..01a2305 --- /dev/null +++ b/app/controllers/ColumnsController.asp @@ -0,0 +1,173 @@ +<% +Class ColumnsController_Class + Private m_useLayout + Private m_title + + Private Sub Class_Initialize() + m_useLayout = False + m_title = "Columns" + End Sub + + Public Property Get useLayout() : useLayout = m_useLayout : End Property + Public Property Let useLayout(v) : m_useLayout = v : End Property + Public Property Get Title() : Title = m_title : End Property + Public Property Let Title(v) : m_title = v : End Property + + ' POST /columns + Public Sub Store() + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + + Dim boardId, colName + boardId = CLng(Request.Form("board_id")) + colName = Trim(CStr(Request.Form("name"))) + + If boardId = 0 Or Len(colName) = 0 Then + Response.Write "{""ok"":false,""error"":""board_id and name are required"",""debug_board_id_raw"":""" & Request.Form("board_id") & """,""debug_name_raw"":""" & Request.Form("name") & """,""debug_board_id_clng"":" & boardId & "}" + Exit Sub + End If + + Dim nextPos : nextPos = board_columns_Repository().MaxPosition(boardId) + 1 + Dim username : username = GetCurrentUsername() + + Dim col : Set col = New POBO_board_columns + col.board_id = boardId + col.name = colName + col.position = nextPos + col.created_at = Now() + col.created_by = username + col.updated_at = Now() + col.updated_by = username + + board_columns_Repository().AddNew col + + Response.Write "{""ok"":true,""id"":" & col.id & ",""name"":" & JsonString(col.name) & ",""position"":" & col.position & "}" + End Sub + + ' POST /columns/:id + Public Sub Update(id) + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + + Dim colName : colName = Trim(CStr(Request.Form("name"))) + If Len(colName) = 0 Then + Response.Write "{""ok"":false,""error"":""name is required""}" + Exit Sub + End If + + On Error Resume Next + Dim col : Set col = board_columns_Repository().FindByID(CLng(id)) + If Err.Number <> 0 Then + Err.Clear + Response.Write "{""ok"":false,""error"":""Not found""}" + Exit Sub + End If + On Error GoTo 0 + + col.name = colName + col.updated_at = Now() + col.updated_by = GetCurrentUsername() + board_columns_Repository().Update col + + Response.Write "{""ok"":true}" + End Sub + + ' POST /columns/:id/delete + Public Sub Destroy(id) + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + + Dim colId : colId = CLng(id) + cards_Repository().DeleteByColumnId colId + board_columns_Repository().Delete colId + + Response.Write "{""ok"":true}" + End Sub + + ' POST /columns/reorder — body: JSON array [{id:1,position:0},{id:2,position:1},...] + Public Sub Reorder() + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + + Dim rawJson : rawJson = GetRawJsonFromRequest() + Dim parser : Set parser = New aspJSON + Dim parsed + + On Error Resume Next + parser.loadJSON rawJson + If Err.Number <> 0 Then + Err.Clear + Set parser = Nothing + Response.Write "{""ok"":false,""error"":""Invalid JSON payload""}" + Exit Sub + End If + Set parsed = parser.data + On Error GoTo 0 + + If parsed Is Nothing Or parsed.Count = 0 Then + Set parser = Nothing + Response.Write "{""ok"":false,""error"":""Invalid JSON payload""}" + Exit Sub + End If + + Dim username : username = GetCurrentUsername() + Dim i, item + On Error Resume Next + For i = 0 To parsed.Count - 1 + Set item = parsed.Item(i) + board_columns_Repository().UpdatePosition CLng(item.Item("id")), CLng(item.Item("position")), Now(), username + If Err.Number <> 0 Then + Err.Clear + On Error GoTo 0 + Set parser = Nothing + Response.Write "{""ok"":false,""error"":""Invalid reorder item at index " & i & """}" + Exit Sub + End If + Next + On Error GoTo 0 + Set parser = Nothing + + Response.Write "{""ok"":true}" + End Sub + + Private Function GetCurrentUsername() + Dim u : Set u = KeycloakCurrentUser() + Dim name : name = "" + If Not u Is Nothing Then + On Error Resume Next + name = CStr(u.Item("preferred_username")) + If Err.Number <> 0 Then + name = "" + Err.Clear + End If + On Error GoTo 0 + End If + GetCurrentUsername = name + End Function + + Private Function JsonString(s) + JsonString = """" & Replace(Replace(CStr(s), "\", "\\"), """", "\""") & """" + End Function + +End Class + +Dim ColumnsController_Class__Singleton +Function ColumnsController() + If IsEmpty(ColumnsController_Class__Singleton) Then + Set ColumnsController_Class__Singleton = New ColumnsController_Class + End If + Set ColumnsController = ColumnsController_Class__Singleton +End Function +%> diff --git a/app/controllers/HomeController.asp b/app/controllers/HomeController.asp index ddee9c4..ab08dd5 100644 --- a/app/controllers/HomeController.asp +++ b/app/controllers/HomeController.asp @@ -25,9 +25,14 @@ Class HomeController_Class End Property Public Sub index() + If Not KeycloakRequireLogin("") Then Exit Sub + Dim totalBoards : totalBoards = boards_Repository().Count() + Dim totalCards : totalCards = cards_Repository().Count() + Dim boardSummaries : Set boardSummaries = boards_Repository().GetBoardSummaries() %> <% + Set boardSummaries = Nothing End Sub End Class diff --git a/app/controllers/SwimLanesController.asp b/app/controllers/SwimLanesController.asp new file mode 100644 index 0000000..fd6dbe4 --- /dev/null +++ b/app/controllers/SwimLanesController.asp @@ -0,0 +1,173 @@ +<% +Class SwimLanesController_Class + Private m_useLayout + Private m_title + + Private Sub Class_Initialize() + m_useLayout = False + m_title = "Swim Lanes" + End Sub + + Public Property Get useLayout() : useLayout = m_useLayout : End Property + Public Property Let useLayout(v) : m_useLayout = v : End Property + Public Property Get Title() : Title = m_title : End Property + Public Property Let Title(v) : m_title = v : End Property + + ' POST /swimlanes + Public Sub Store() + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + + Dim boardId, laneName + boardId = CLng(Request.Form("board_id")) + laneName = Trim(CStr(Request.Form("name"))) + + If boardId = 0 Or Len(laneName) = 0 Then + Response.Write "{""ok"":false,""error"":""board_id and name are required""}" + Exit Sub + End If + + Dim nextPos : nextPos = swim_lanes_Repository().MaxPosition(boardId) + 1 + Dim username : username = GetCurrentUsername() + + Dim lane : Set lane = New POBO_swim_lanes + lane.board_id = boardId + lane.name = laneName + lane.position = nextPos + lane.created_at = Now() + lane.created_by = username + lane.updated_at = Now() + lane.updated_by = username + + swim_lanes_Repository().AddNew lane + + Response.Write "{""ok"":true,""id"":" & lane.id & ",""name"":" & JsonString(lane.name) & ",""position"":" & lane.position & "}" + End Sub + + ' POST /swimlanes/:id + Public Sub Update(id) + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + + Dim laneName : laneName = Trim(CStr(Request.Form("name"))) + If Len(laneName) = 0 Then + Response.Write "{""ok"":false,""error"":""name is required""}" + Exit Sub + End If + + On Error Resume Next + Dim lane : Set lane = swim_lanes_Repository().FindByID(CLng(id)) + If Err.Number <> 0 Then + Err.Clear + Response.Write "{""ok"":false,""error"":""Not found""}" + Exit Sub + End If + On Error GoTo 0 + + lane.name = laneName + lane.updated_at = Now() + lane.updated_by = GetCurrentUsername() + swim_lanes_Repository().Update lane + + Response.Write "{""ok"":true}" + End Sub + + ' POST /swimlanes/:id/delete + Public Sub Destroy(id) + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + + Dim laneId : laneId = CLng(id) + cards_Repository().DeleteBySwimLaneId laneId + swim_lanes_Repository().Delete laneId + + Response.Write "{""ok"":true}" + End Sub + + ' POST /swimlanes/reorder — body: JSON array [{id:1,position:0},{id:2,position:1},...] + Public Sub Reorder() + Response.ContentType = "application/json" + If Not KeycloakIsLoggedIn() Then + Response.Write "{""ok"":false,""error"":""Unauthorized""}" + Exit Sub + End If + + Dim rawJson : rawJson = GetRawJsonFromRequest() + Dim parser : Set parser = New aspJSON + Dim parsed + + On Error Resume Next + parser.loadJSON rawJson + If Err.Number <> 0 Then + Err.Clear + Set parser = Nothing + Response.Write "{""ok"":false,""error"":""Invalid JSON payload""}" + Exit Sub + End If + Set parsed = parser.data + On Error GoTo 0 + + If parsed Is Nothing Or parsed.Count = 0 Then + Set parser = Nothing + Response.Write "{""ok"":false,""error"":""Invalid JSON payload""}" + Exit Sub + End If + + Dim username : username = GetCurrentUsername() + Dim i, item + On Error Resume Next + For i = 0 To parsed.Count - 1 + Set item = parsed.Item(i) + swim_lanes_Repository().UpdatePosition CLng(item.Item("id")), CLng(item.Item("position")), Now(), username + If Err.Number <> 0 Then + Err.Clear + On Error GoTo 0 + Set parser = Nothing + Response.Write "{""ok"":false,""error"":""Invalid reorder item at index " & i & """}" + Exit Sub + End If + Next + On Error GoTo 0 + Set parser = Nothing + + Response.Write "{""ok"":true}" + End Sub + + Private Function GetCurrentUsername() + Dim u : Set u = KeycloakCurrentUser() + Dim name : name = "" + If Not u Is Nothing Then + On Error Resume Next + name = CStr(u.Item("preferred_username")) + If Err.Number <> 0 Then + name = "" + Err.Clear + End If + On Error GoTo 0 + End If + GetCurrentUsername = name + End Function + + Private Function JsonString(s) + JsonString = """" & Replace(Replace(CStr(s), "\", "\\"), """", "\""") & """" + End Function + +End Class + +Dim SwimLanesController_Class__Singleton +Function SwimLanesController() + If IsEmpty(SwimLanesController_Class__Singleton) Then + Set SwimLanesController_Class__Singleton = New SwimLanesController_Class + End If + Set SwimLanesController = SwimLanesController_Class__Singleton +End Function +%> diff --git a/app/controllers/autoload_controllers.asp b/app/controllers/autoload_controllers.asp index a807bfa..1f1c226 100644 --- a/app/controllers/autoload_controllers.asp +++ b/app/controllers/autoload_controllers.asp @@ -1,3 +1,15 @@ - \ No newline at end of file + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/models/POBO_board_columns.asp b/app/models/POBO_board_columns.asp new file mode 100644 index 0000000..7442fe8 --- /dev/null +++ b/app/models/POBO_board_columns.asp @@ -0,0 +1,53 @@ +<% +Class POBO_board_columns + Public Properties + + Private p_id + Private p_board_id + Private p_name + Private p_position + Private p_created_at + Private p_created_by + Private p_updated_at + Private p_updated_by + + Private Sub Class_Initialize() + p_id = 0 + p_board_id = 0 + p_name = "" + p_position = 0 + p_created_at = #1/1/1970# + p_created_by = "" + p_updated_at = #1/1/1970# + p_updated_by = "" + Properties = Array("id","board_id","name","position","created_at","created_by","updated_at","updated_by") + End Sub + + Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property + Public Property Get TableName() : TableName = "board_columns" : End Property + + Public Property Get id() : id = p_id : End Property + Public Property Let id(v) : p_id = CDbl(v) : End Property + + Public Property Get board_id() : board_id = p_board_id : End Property + Public Property Let board_id(v): p_board_id = CDbl(v) : End Property + + Public Property Get name() : name = p_name : End Property + Public Property Let name(v) : p_name = CStr(v) : End Property + + Public Property Get position() : position = p_position : End Property + Public Property Let position(v): p_position = CDbl(v) : End Property + + Public Property Get created_at() : created_at = p_created_at : End Property + Public Property Let created_at(v) : p_created_at = CDate(v) : End Property + + Public Property Get created_by() : created_by = p_created_by : End Property + Public Property Let created_by(v) : p_created_by = CStr(v) : End Property + + Public Property Get updated_at() : updated_at = p_updated_at : End Property + Public Property Let updated_at(v) : p_updated_at = CDate(v) : End Property + + Public Property Get updated_by() : updated_by = p_updated_by : End Property + Public Property Let updated_by(v) : p_updated_by = CStr(v) : End Property +End Class +%> diff --git a/app/models/POBO_boards.asp b/app/models/POBO_boards.asp new file mode 100644 index 0000000..b50446d --- /dev/null +++ b/app/models/POBO_boards.asp @@ -0,0 +1,58 @@ +<% +Class POBO_boards + Public Properties + + Private p_id + Private p_name + Private p_slug + Private p_import_from_printstream + Private p_printstream_job_name + Private p_created_at + Private p_created_by + Private p_updated_at + Private p_updated_by + + Private Sub Class_Initialize() + p_id = 0 + p_name = "" + p_slug = "" + p_import_from_printstream = False + p_printstream_job_name = "" + p_created_at = #1/1/1970# + p_created_by = "" + p_updated_at = #1/1/1970# + p_updated_by = "" + Properties = Array("id","name","slug","import_from_printstream","printstream_job_name","created_at","created_by","updated_at","updated_by") + End Sub + + Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property + Public Property Get TableName() : TableName = "boards" : End Property + + Public Property Get id() : id = p_id : End Property + Public Property Let id(v) : p_id = CDbl(v) : End Property + + Public Property Get name() : name = p_name : End Property + Public Property Let name(v) : p_name = CStr(v) : End Property + + Public Property Get slug() : slug = p_slug : End Property + Public Property Let slug(v) : p_slug = CStr(v) : End Property + + Public Property Get import_from_printstream() : import_from_printstream = p_import_from_printstream : End Property + Public Property Let import_from_printstream(v) : p_import_from_printstream = CBool(v) : End Property + + Public Property Get printstream_job_name() : printstream_job_name = p_printstream_job_name : End Property + Public Property Let printstream_job_name(v) : p_printstream_job_name = CStr(v) : End Property + + Public Property Get created_at() : created_at = p_created_at : End Property + Public Property Let created_at(v) : p_created_at = CDate(v) : End Property + + Public Property Get created_by() : created_by = p_created_by : End Property + Public Property Let created_by(v) : p_created_by = CStr(v) : End Property + + Public Property Get updated_at() : updated_at = p_updated_at : End Property + Public Property Let updated_at(v) : p_updated_at = CDate(v) : End Property + + Public Property Get updated_by() : updated_by = p_updated_by : End Property + Public Property Let updated_by(v) : p_updated_by = CStr(v) : End Property +End Class +%> diff --git a/app/models/POBO_cards.asp b/app/models/POBO_cards.asp new file mode 100644 index 0000000..dd26dc9 --- /dev/null +++ b/app/models/POBO_cards.asp @@ -0,0 +1,95 @@ +<% +Class POBO_cards + Public Properties + + Private p_id + Private p_board_id + Private p_column_id + Private p_swim_lane_id + Private p_job_number + Private p_job_name + Private p_customer_name + Private p_delivery_date + Private p_quantity + Private p_notes + Private p_full_note + Private p_position + Private p_created_at + Private p_created_by + Private p_updated_at + Private p_updated_by + + Private Sub Class_Initialize() + p_id = 0 + p_board_id = 0 + p_column_id = 0 + p_swim_lane_id = 0 + p_job_number = "" + p_job_name = "" + p_customer_name = "" + p_delivery_date = Null + p_quantity = "" + p_notes = "" + p_full_note = "" + p_position = 0 + p_created_at = #1/1/1970# + p_created_by = "" + p_updated_at = #1/1/1970# + p_updated_by = "" + Properties = Array("id","board_id","column_id","swim_lane_id","job_number","job_name","customer_name","delivery_date","quantity","notes","full_note","position","created_at","created_by","updated_at","updated_by") + End Sub + + Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property + Public Property Get TableName() : TableName = "cards" : End Property + + Public Property Get id() : id = p_id : End Property + Public Property Let id(v) : p_id = CDbl(v) : End Property + + Public Property Get board_id() : board_id = p_board_id : End Property + Public Property Let board_id(v) : p_board_id = CDbl(v) : End Property + + Public Property Get column_id() : column_id = p_column_id : End Property + Public Property Let column_id(v) : p_column_id = CDbl(v) : End Property + + Public Property Get swim_lane_id() : swim_lane_id = p_swim_lane_id : End Property + Public Property Let swim_lane_id(v): p_swim_lane_id = CDbl(v) : End Property + + Public Property Get job_number() : job_number = p_job_number : End Property + Public Property Let job_number(v) : p_job_number = CStr(v) : End Property + + Public Property Get job_name() : job_name = p_job_name : End Property + Public Property Let job_name(v) : p_job_name = CStr(v) : End Property + + Public Property Get customer_name() : customer_name = p_customer_name : End Property + Public Property Let customer_name(v) : p_customer_name = CStr(v & "") : End Property + + Public Property Get delivery_date() : delivery_date = p_delivery_date : End Property + Public Property Let delivery_date(v) + If IsDate(v) Then p_delivery_date = CDate(v) Else p_delivery_date = Null + End Property + + Public Property Get quantity() : quantity = p_quantity : End Property + Public Property Let quantity(v) : p_quantity = CStr(v & "") : End Property + + Public Property Get notes() : notes = p_notes : End Property + Public Property Let notes(v) : p_notes = CStr(v & "") : End Property + + Public Property Get full_note() : full_note = p_full_note : End Property + Public Property Let full_note(v) : p_full_note = CStr(v & "") : End Property + + Public Property Get position() : position = p_position : End Property + Public Property Let position(v) : p_position = CDbl(v) : End Property + + Public Property Get created_at() : created_at = p_created_at : End Property + Public Property Let created_at(v) : p_created_at = CDate(v) : End Property + + Public Property Get created_by() : created_by = p_created_by : End Property + Public Property Let created_by(v) : p_created_by = CStr(v) : End Property + + Public Property Get updated_at() : updated_at = p_updated_at : End Property + Public Property Let updated_at(v) : p_updated_at = CDate(v) : End Property + + Public Property Get updated_by() : updated_by = p_updated_by : End Property + Public Property Let updated_by(v) : p_updated_by = CStr(v) : End Property +End Class +%> diff --git a/app/models/POBO_swim_lanes.asp b/app/models/POBO_swim_lanes.asp new file mode 100644 index 0000000..6e5d613 --- /dev/null +++ b/app/models/POBO_swim_lanes.asp @@ -0,0 +1,53 @@ +<% +Class POBO_swim_lanes + Public Properties + + Private p_id + Private p_board_id + Private p_name + Private p_position + Private p_created_at + Private p_created_by + Private p_updated_at + Private p_updated_by + + Private Sub Class_Initialize() + p_id = 0 + p_board_id = 0 + p_name = "" + p_position = 0 + p_created_at = #1/1/1970# + p_created_by = "" + p_updated_at = #1/1/1970# + p_updated_by = "" + Properties = Array("id","board_id","name","position","created_at","created_by","updated_at","updated_by") + End Sub + + Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property + Public Property Get TableName() : TableName = "swim_lanes" : End Property + + Public Property Get id() : id = p_id : End Property + Public Property Let id(v) : p_id = CDbl(v) : End Property + + Public Property Get board_id() : board_id = p_board_id : End Property + Public Property Let board_id(v): p_board_id = CDbl(v) : End Property + + Public Property Get name() : name = p_name : End Property + Public Property Let name(v) : p_name = CStr(v) : End Property + + Public Property Get position() : position = p_position : End Property + Public Property Let position(v): p_position = CDbl(v) : End Property + + Public Property Get created_at() : created_at = p_created_at : End Property + Public Property Let created_at(v) : p_created_at = CDate(v) : End Property + + Public Property Get created_by() : created_by = p_created_by : End Property + Public Property Let created_by(v) : p_created_by = CStr(v) : End Property + + Public Property Get updated_at() : updated_at = p_updated_at : End Property + Public Property Let updated_at(v) : p_updated_at = CDate(v) : End Property + + Public Property Get updated_by() : updated_by = p_updated_by : End Property + Public Property Let updated_by(v) : p_updated_by = CStr(v) : End Property +End Class +%> diff --git a/app/repositories/board_columns_Repository.asp b/app/repositories/board_columns_Repository.asp new file mode 100644 index 0000000..5e210e1 --- /dev/null +++ b/app/repositories/board_columns_Repository.asp @@ -0,0 +1,75 @@ +<% +Class board_columns_Repository_Class + + Public Function FindByID(id) + Dim sql : sql = "SELECT [id],[board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by] FROM [board_columns] WHERE [id] = ?" + Dim rs : Set rs = DAL.Query(sql, Array(id)) + If rs.EOF Then + Err.Raise 1, "board_columns_Repository_Class", "Column not found with id = " & id + Else + Set FindByID = Automapper.AutoMap(rs, "POBO_board_columns") + End If + Destroy rs + End Function + + Public Function FindByBoardId(boardId) + Dim sql : sql = "SELECT [id],[board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by] FROM [board_columns] WHERE [board_id] = ? ORDER BY [position] ASC" + Dim rs : Set rs = DAL.Query(sql, Array(boardId)) + Dim list : Set list = New LinkedList_Class + Do Until rs.EOF + list.Push Automapper.AutoMap(rs, "POBO_board_columns") + rs.MoveNext + Loop + Set FindByBoardId = list + Destroy rs + End Function + + Public Function MaxPosition(boardId) + Dim sql : sql = "SELECT MAX([position]) FROM [board_columns] WHERE [board_id] = ?" + Dim rs : Set rs = DAL.Query(sql, Array(boardId)) + If rs.EOF Or IsNull(rs(0)) Then + MaxPosition = -1 + Else + MaxPosition = CLng(rs(0)) + End If + Destroy rs + End Function + + Public Sub AddNew(ByRef model) + Dim sql : sql = "INSERT INTO [board_columns] ([board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?,?)" + DAL.Execute sql, Array(model.board_id, model.name, model.position, model.created_at, model.created_by, model.updated_at, model.updated_by) + Dim rsId : Set rsId = DAL.Query("SELECT @@IDENTITY AS NewID", Empty) + If Not rsId.EOF Then + If Not IsNull(rsId(0)) Then model.id = rsId(0) + End If + Destroy rsId + End Sub + + Public Sub Update(model) + Dim sql : sql = "UPDATE [board_columns] SET [name]=?,[position]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" + DAL.Execute sql, Array(model.name, model.position, model.updated_at, model.updated_by, model.id) + End Sub + + Public Sub UpdatePosition(id, position, updatedAt, updatedBy) + Dim sql : sql = "UPDATE [board_columns] SET [position]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" + DAL.Execute sql, Array(position, updatedAt, updatedBy, id) + End Sub + + Public Sub Delete(id) + DAL.Execute "DELETE FROM [board_columns] WHERE [id]=?", Array(id) + End Sub + + Public Sub DeleteByBoardId(boardId) + DAL.Execute "DELETE FROM [board_columns] WHERE [board_id]=?", Array(boardId) + End Sub + +End Class + +Dim board_columns_Repository__Singleton +Function board_columns_Repository() + If IsEmpty(board_columns_Repository__Singleton) Then + Set board_columns_Repository__Singleton = New board_columns_Repository_Class + End If + Set board_columns_Repository = board_columns_Repository__Singleton +End Function +%> diff --git a/app/repositories/boards_Repository.asp b/app/repositories/boards_Repository.asp new file mode 100644 index 0000000..14e7d59 --- /dev/null +++ b/app/repositories/boards_Repository.asp @@ -0,0 +1,116 @@ +<% +Class boards_Repository_Class + + Public Function FindByID(id) + Dim sql : sql = "SELECT [id],[name],[slug],[import_from_printstream],[printstream_job_name],[created_at],[created_by],[updated_at],[updated_by] FROM [boards] WHERE [id] = ?" + Dim rs : Set rs = DAL.Query(sql, Array(id)) + If rs.EOF Then + Err.Raise 1, "boards_Repository_Class", "Board not found with id = " & id + Else + Set FindByID = Automapper.AutoMap(rs, "POBO_boards") + End If + Destroy rs + End Function + + Public Function FindBySlug(slug) + Dim sql : sql = "SELECT [id],[name],[slug],[import_from_printstream],[printstream_job_name],[created_at],[created_by],[updated_at],[updated_by] FROM [boards] WHERE [slug] = ?" + Dim rs : Set rs = DAL.Query(sql, Array(slug)) + If rs.EOF Then + Set FindBySlug = Nothing + Else + Set FindBySlug = Automapper.AutoMap(rs, "POBO_boards") + End If + Destroy rs + End Function + + Public Function GetAll() + Dim sql : sql = "SELECT [id],[name],[slug],[import_from_printstream],[printstream_job_name],[created_at],[created_by],[updated_at],[updated_by] FROM [boards] ORDER BY [name] ASC" + Dim rs : Set rs = DAL.Query(sql, Empty) + Dim list : Set list = New LinkedList_Class + Do Until rs.EOF + list.Push Automapper.AutoMap(rs, "POBO_boards") + rs.MoveNext + Loop + Set GetAll = list + Destroy rs + End Function + + Public Function SlugExists(slug, excludeId) + Dim sql, rs + If CLng(excludeId) > 0 Then + sql = "SELECT COUNT(*) FROM [boards] WHERE [slug] = ? AND [id] <> ?" + Set rs = DAL.Query(sql, Array(slug, excludeId)) + Else + sql = "SELECT COUNT(*) FROM [boards] WHERE [slug] = ?" + Set rs = DAL.Query(sql, Array(slug)) + End If + SlugExists = (rs(0) > 0) + Destroy rs + End Function + + Public Function UniqueSlug(baseSlug, excludeId) + Dim candidate, suffix + candidate = baseSlug + suffix = 2 + Do While SlugExists(candidate, excludeId) + candidate = baseSlug & "-" & suffix + suffix = suffix + 1 + Loop + UniqueSlug = candidate + End Function + + Public Sub AddNew(ByRef model) + Dim sql : sql = "INSERT INTO [boards] ([name],[slug],[import_from_printstream],[printstream_job_name],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?,?,?)" + DAL.Execute sql, Array(model.name, model.slug, model.import_from_printstream, model.printstream_job_name, model.created_at, model.created_by, model.updated_at, model.updated_by) + Dim rsId : Set rsId = DAL.Query("SELECT @@IDENTITY AS NewID", Empty) + If Not rsId.EOF Then + If Not IsNull(rsId(0)) Then model.id = rsId(0) + End If + Destroy rsId + End Sub + + Public Sub Update(model) + Dim sql : sql = "UPDATE [boards] SET [name]=?,[slug]=?,[import_from_printstream]=?,[printstream_job_name]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" + DAL.Execute sql, Array(model.name, model.slug, model.import_from_printstream, model.printstream_job_name, model.updated_at, model.updated_by, model.id) + End Sub + + Public Sub Delete(id) + DAL.Execute "DELETE FROM [boards] WHERE [id]=?", Array(id) + End Sub + + Public Function Count() + Dim rs : Set rs = DAL.Query("SELECT COUNT(*) FROM [boards]", Empty) + Count = CLng(rs(0)) + Destroy rs + End Function + + Public Function GetBoardSummaries() + Dim sql : sql = "SELECT b.[name], b.[slug], COUNT(c.[id]) AS [card_count] " & _ + "FROM [boards] b " & _ + "LEFT JOIN [cards] c ON c.[board_id] = b.[id] " & _ + "GROUP BY b.[name], b.[slug] " & _ + "ORDER BY b.[name]" + Dim rs : Set rs = DAL.Query(sql, Empty) + Dim list : Set list = New LinkedList_Class + Do Until rs.EOF + Dim d : Set d = CreateObject("Scripting.Dictionary") + d.Add "name", CStr(rs("name")) + d.Add "slug", CStr(rs("slug")) + d.Add "card_count", CLng(rs("card_count")) + list.Push d + rs.MoveNext + Loop + Set GetBoardSummaries = list + Destroy rs + End Function + +End Class + +Dim boards_Repository__Singleton +Function boards_Repository() + If IsEmpty(boards_Repository__Singleton) Then + Set boards_Repository__Singleton = New boards_Repository_Class + End If + Set boards_Repository = boards_Repository__Singleton +End Function +%> diff --git a/app/repositories/cards_Repository.asp b/app/repositories/cards_Repository.asp new file mode 100644 index 0000000..90a322a --- /dev/null +++ b/app/repositories/cards_Repository.asp @@ -0,0 +1,128 @@ +<% +Class cards_Repository_Class + + Private Function SelectBase() + SelectBase = "SELECT [id],[board_id],[column_id],[swim_lane_id],[job_number],[job_name],[customer_name],[delivery_date],[quantity],[notes],[full_note],[position],[created_at],[created_by],[updated_at],[updated_by] FROM [cards]" + End Function + + Public Function FindByID(id) + Dim sql : sql = SelectBase() & " WHERE [id] = ?" + Dim rs : Set rs = DAL.Query(sql, Array(id)) + If rs.EOF Then + Err.Raise 1, "cards_Repository_Class", "Card not found with id = " & id + Else + Set FindByID = Automapper.AutoMap(rs, "POBO_cards") + End If + Destroy rs + End Function + + Public Function FindByBoardId(boardId) + Dim sql : sql = SelectBase() & " WHERE [board_id] = ? ORDER BY [swim_lane_id] ASC, [column_id] ASC, [position] ASC" + Dim rs : Set rs = DAL.Query(sql, Array(boardId)) + Dim list : Set list = New LinkedList_Class + Do Until rs.EOF + list.Push Automapper.AutoMap(rs, "POBO_cards") + rs.MoveNext + Loop + Set FindByBoardId = list + Destroy rs + End Function + + Public Function FindByCell(columnId, swimLaneId) + Dim sql : sql = SelectBase() & " WHERE [column_id] = ? AND [swim_lane_id] = ? ORDER BY [position] ASC" + Dim rs : Set rs = DAL.Query(sql, Array(columnId, swimLaneId)) + Dim list : Set list = New LinkedList_Class + Do Until rs.EOF + list.Push Automapper.AutoMap(rs, "POBO_cards") + rs.MoveNext + Loop + Set FindByCell = list + Destroy rs + End Function + + Public Function MaxPosition(columnId, swimLaneId) + Dim sql : sql = "SELECT MAX([position]) FROM [cards] WHERE [column_id] = ? AND [swim_lane_id] = ?" + Dim rs : Set rs = DAL.Query(sql, Array(columnId, swimLaneId)) + If rs.EOF Or IsNull(rs(0)) Then + MaxPosition = -1 + Else + MaxPosition = CLng(rs(0)) + End If + Destroy rs + End Function + + Public Sub AddNew(ByRef model) + Dim sql, params + If QuantityIsBlank(model.quantity) Then + sql = "INSERT INTO [cards] ([board_id],[column_id],[swim_lane_id],[job_number],[job_name],[customer_name],[delivery_date],[quantity],[notes],[full_note],[position],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?,?,NULL,?,?,?,?,?,?,?)" + params = Array(model.board_id, model.column_id, model.swim_lane_id, model.job_number, model.job_name, model.customer_name, model.delivery_date, model.notes, model.full_note, model.position, model.created_at, model.created_by, model.updated_at, model.updated_by) + Else + sql = "INSERT INTO [cards] ([board_id],[column_id],[swim_lane_id],[job_number],[job_name],[customer_name],[delivery_date],[quantity],[notes],[full_note],[position],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + params = Array(model.board_id, model.column_id, model.swim_lane_id, model.job_number, model.job_name, model.customer_name, model.delivery_date, model.quantity, model.notes, model.full_note, model.position, model.created_at, model.created_by, model.updated_at, model.updated_by) + End If + DAL.Execute sql, params + Dim rsId : Set rsId = DAL.Query("SELECT @@IDENTITY AS NewID", Empty) + If Not rsId.EOF Then + If Not IsNull(rsId(0)) Then model.id = rsId(0) + End If + Destroy rsId + End Sub + + Public Sub Update(model) + Dim sql, params + If QuantityIsBlank(model.quantity) Then + sql = "UPDATE [cards] SET [job_number]=?,[job_name]=?,[customer_name]=?,[delivery_date]=?,[quantity]=NULL,[notes]=?,[full_note]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" + params = Array(model.job_number, model.job_name, model.customer_name, model.delivery_date, model.notes, model.full_note, model.updated_at, model.updated_by, model.id) + Else + sql = "UPDATE [cards] SET [job_number]=?,[job_name]=?,[customer_name]=?,[delivery_date]=?,[quantity]=?,[notes]=?,[full_note]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" + params = Array(model.job_number, model.job_name, model.customer_name, model.delivery_date, model.quantity, model.notes, model.full_note, model.updated_at, model.updated_by, model.id) + End If + DAL.Execute sql, params + End Sub + + Private Function QuantityIsBlank(v) + QuantityIsBlank = (Len(Trim(CStr(v & ""))) = 0) + End Function + + Public Sub Move(id, columnId, swimLaneId, position, updatedAt, updatedBy) + Dim sql : sql = "UPDATE [cards] SET [column_id]=?,[swim_lane_id]=?,[position]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" + DAL.Execute sql, Array(columnId, swimLaneId, position, updatedAt, updatedBy, id) + End Sub + + Public Sub UpdatePosition(id, position, updatedAt, updatedBy) + Dim sql : sql = "UPDATE [cards] SET [position]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" + DAL.Execute sql, Array(position, updatedAt, updatedBy, id) + End Sub + + Public Sub Delete(id) + DAL.Execute "DELETE FROM [cards] WHERE [id]=?", Array(id) + End Sub + + Public Sub DeleteByBoardId(boardId) + DAL.Execute "DELETE FROM [cards] WHERE [board_id]=?", Array(boardId) + End Sub + + Public Sub DeleteByColumnId(columnId) + DAL.Execute "DELETE FROM [cards] WHERE [column_id]=?", Array(columnId) + End Sub + + Public Sub DeleteBySwimLaneId(swimLaneId) + DAL.Execute "DELETE FROM [cards] WHERE [swim_lane_id]=?", Array(swimLaneId) + End Sub + + Public Function Count() + Dim rs : Set rs = DAL.Query("SELECT COUNT(*) FROM [cards]", Empty) + Count = CLng(rs(0)) + Destroy rs + End Function + +End Class + +Dim cards_Repository__Singleton +Function cards_Repository() + If IsEmpty(cards_Repository__Singleton) Then + Set cards_Repository__Singleton = New cards_Repository_Class + End If + Set cards_Repository = cards_Repository__Singleton +End Function +%> diff --git a/app/repositories/swim_lanes_Repository.asp b/app/repositories/swim_lanes_Repository.asp new file mode 100644 index 0000000..a8a7e79 --- /dev/null +++ b/app/repositories/swim_lanes_Repository.asp @@ -0,0 +1,75 @@ +<% +Class swim_lanes_Repository_Class + + Public Function FindByID(id) + Dim sql : sql = "SELECT [id],[board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by] FROM [swim_lanes] WHERE [id] = ?" + Dim rs : Set rs = DAL.Query(sql, Array(id)) + If rs.EOF Then + Err.Raise 1, "swim_lanes_Repository_Class", "Swim lane not found with id = " & id + Else + Set FindByID = Automapper.AutoMap(rs, "POBO_swim_lanes") + End If + Destroy rs + End Function + + Public Function FindByBoardId(boardId) + Dim sql : sql = "SELECT [id],[board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by] FROM [swim_lanes] WHERE [board_id] = ? ORDER BY [position] ASC" + Dim rs : Set rs = DAL.Query(sql, Array(boardId)) + Dim list : Set list = New LinkedList_Class + Do Until rs.EOF + list.Push Automapper.AutoMap(rs, "POBO_swim_lanes") + rs.MoveNext + Loop + Set FindByBoardId = list + Destroy rs + End Function + + Public Function MaxPosition(boardId) + Dim sql : sql = "SELECT MAX([position]) FROM [swim_lanes] WHERE [board_id] = ?" + Dim rs : Set rs = DAL.Query(sql, Array(boardId)) + If rs.EOF Or IsNull(rs(0)) Then + MaxPosition = -1 + Else + MaxPosition = CLng(rs(0)) + End If + Destroy rs + End Function + + Public Sub AddNew(ByRef model) + Dim sql : sql = "INSERT INTO [swim_lanes] ([board_id],[name],[position],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?,?)" + DAL.Execute sql, Array(model.board_id, model.name, model.position, model.created_at, model.created_by, model.updated_at, model.updated_by) + Dim rsId : Set rsId = DAL.Query("SELECT @@IDENTITY AS NewID", Empty) + If Not rsId.EOF Then + If Not IsNull(rsId(0)) Then model.id = rsId(0) + End If + Destroy rsId + End Sub + + Public Sub Update(model) + Dim sql : sql = "UPDATE [swim_lanes] SET [name]=?,[position]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" + DAL.Execute sql, Array(model.name, model.position, model.updated_at, model.updated_by, model.id) + End Sub + + Public Sub UpdatePosition(id, position, updatedAt, updatedBy) + Dim sql : sql = "UPDATE [swim_lanes] SET [position]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" + DAL.Execute sql, Array(position, updatedAt, updatedBy, id) + End Sub + + Public Sub Delete(id) + DAL.Execute "DELETE FROM [swim_lanes] WHERE [id]=?", Array(id) + End Sub + + Public Sub DeleteByBoardId(boardId) + DAL.Execute "DELETE FROM [swim_lanes] WHERE [board_id]=?", Array(boardId) + End Sub + +End Class + +Dim swim_lanes_Repository__Singleton +Function swim_lanes_Repository() + If IsEmpty(swim_lanes_Repository__Singleton) Then + Set swim_lanes_Repository__Singleton = New swim_lanes_Repository_Class + End If + Set swim_lanes_Repository = swim_lanes_Repository__Singleton +End Function +%> diff --git a/app/views/Boards/Create.asp b/app/views/Boards/Create.asp new file mode 100644 index 0000000..9e941d1 --- /dev/null +++ b/app/views/Boards/Create.asp @@ -0,0 +1,68 @@ +
+
+
+ + + +

New Board

+
+ +
+
+
+
+ + +
+
+ +
 
+
+
+
+ + +
+
+ +
+ + Cancel +
+
+
+
+
+
+ + diff --git a/app/views/Boards/Edit.asp b/app/views/Boards/Edit.asp new file mode 100644 index 0000000..f3a24e9 --- /dev/null +++ b/app/views/Boards/Edit.asp @@ -0,0 +1,66 @@ +
+
+
+ + + +

Edit Board

+
+ +
+
+
+
+ + +
+
+ +
<%= H(board.slug) %>
+
+
+
+ /> + +
+
+
"> + + +
+
+ + Cancel +
+
+
+
+ +
+
+
Delete Board
+

This will permanently delete the board, all its columns, swim lanes, and cards.

+
+ +
+
+
+
+
+ + diff --git a/app/views/Boards/Index.asp b/app/views/Boards/Index.asp new file mode 100644 index 0000000..2cd68d3 --- /dev/null +++ b/app/views/Boards/Index.asp @@ -0,0 +1,38 @@ +<% +Dim boardItem +%> +
+

Boards

+ + New Board + +
+ +<% If boards.Count = 0 Then %> +
+ +

No boards yet.

+ Create your first board +
+<% Else %> +
+ <% Dim boardIter : Set boardIter = boards.Iterator() %> + <% Do While boardIter.HasNext() %> + <% Set boardItem = boardIter.GetNext() %> +
+
+
+
<%= H(boardItem.name) %>
+

<%= H(boardItem.slug) %>

+
+ Open + + + +
+
+
+
+ <% Loop %> +
+<% End If %> diff --git a/app/views/Boards/SettingsPanel.asp b/app/views/Boards/SettingsPanel.asp new file mode 100644 index 0000000..66c2a7a --- /dev/null +++ b/app/views/Boards/SettingsPanel.asp @@ -0,0 +1,83 @@ + +
+ +
+
+
Board Settings
+ +
+ +
+ + +
+
+ Columns + +
+
+
+ + + +
+
+
    + <% Dim sCIdx + For sCIdx = 0 To colCount - 1 + Set colItem = colsArr(sCIdx) %> +
  • + + <%= H(colItem.name) %> + + +
  • + <% Next %> +
+
+ + +
+
+ Swim Lanes + +
+
+
+ + + +
+
+
    + <% Dim sLIdx + For sLIdx = 0 To laneCount - 1 + Set laneItem = lanesArr(sLIdx) %> +
  • + + <%= H(laneItem.name) %> + + +
  • + <% Next %> +
+
+ +
+
diff --git a/app/views/Boards/Show.asp b/app/views/Boards/Show.asp new file mode 100644 index 0000000..4e0b162 --- /dev/null +++ b/app/views/Boards/Show.asp @@ -0,0 +1,117 @@ + +<% +Response.Charset = "utf-8" +Response.CodePage = 65001 +%> + + + + <%= H(board.name) %> — Kanban + + + + + + + + + + + + + + + +
+
+ + +
+ + + <% Dim vColIdx, vColItem + For vColIdx = 0 To colCount - 1 + Set vColItem = colsArr(vColIdx) %> +
+ <%= H(vColItem.name) %> +
+ <% Next %> + + + <% Dim vLaneIdx, vLaneItem + For vLaneIdx = 0 To laneCount - 1 + Set vLaneItem = lanesArr(vLaneIdx) %> + + +
+ + <%= H(vLaneItem.name) %> +
+ + + <% For vColIdx = 0 To colCount - 1 + Set vColItem = colsArr(vColIdx) %> +
+
+ <% Next %> + + <% Next %> + +
+
+ + + + + + + + + + + + + diff --git a/app/views/Cards/_modal.asp b/app/views/Cards/_modal.asp new file mode 100644 index 0000000..609bbc2 --- /dev/null +++ b/app/views/Cards/_modal.asp @@ -0,0 +1,54 @@ + + diff --git a/app/views/Home/index.asp b/app/views/Home/index.asp index 2874882..2235262 100644 --- a/app/views/Home/index.asp +++ b/app/views/Home/index.asp @@ -1,84 +1,106 @@ +
+
+

Dashboard

+
+
+ + New Board + +
+
-
-
-
-
-

Welcome to RouteKit Classic ASP

-

- Your lightweight, opinionated MVC-style framework for Classic ASP. -

-

- This Home.Index view is using the shared - Header.asp and Footer.asp layout files. -

-

- Start by wiring up your controllers, repositories, and views — this page is just a - friendly placeholder so you know everything is hooked up correctly. -

+ +
+
+
+
+
<%= totalBoards %>
+
+ Boards +
- -
-
-
-

Quick info

-
    -
  • - View: - app/Views/Home.Index.asp -
  • -
  • - Layout: - Shared/Header.asp & Shared/Footer.asp -
  • -
  • - Default route: - typically /Home/Index or / via the dispatcher. -
  • -
+
+
+
+
<%= totalCards %>
+
+ Total Cards +
-
-
-
-
-

Next step: Controllers

-

- Use your generateController.vbs script to scaffold new controllers. -

-
cscript //nologo Scripts\generateController.vbs ^
-  Home "Index"
+ +
+
+
+
+ Cards per Board
-
-
- -
-
-
-

POBO & Repository

-

- Generate strongly-typed POBOs and repositories from your Access/SQL schema. -

-
cscript //nologo Scripts\GenerateRepo.vbs ^
-  /table:Users /pk:UserId
+ <% If boardSummaries.Count = 0 Then %> +
+ No boards yet. Create one to get started.
+ <% Else %> +
+ + + + + + + + + + <% + Dim summaryIter : Set summaryIter = boardSummaries.Iterator() + Do While summaryIter.HasNext() + Dim summary : Set summary = summaryIter.GetNext() + Dim cardCount : cardCount = CLng(summary("card_count")) + %> + + + + + + <% Loop %> + +
BoardCards
+ " class="text-decoration-none fw-medium"> + <%= H(summary("name")) %> + + + <% If cardCount = 0 Then %> + + <% Else %> + <%= cardCount %> + <% End If %> + + " class="btn btn-sm btn-outline-primary"> + Open + +
+
+ <% End If %>
-
-
-
-

Where to put stuff

-
    -
  • /core/ – framework libs (DAL, routing, helpers)
  • -
  • /app/Views/ – pages like this one
  • -
  • /public/ – IIS root (Default.asp, web.config)
  • -
+
+
+
+ Quick Links +
+
diff --git a/app/views/shared/header.asp b/app/views/shared/header.asp index 057b525..878c0a3 100644 --- a/app/views/shared/header.asp +++ b/app/views/shared/header.asp @@ -9,13 +9,13 @@ If IsObject(CurrentController) Then On Error Resume Next pageTitle = CurrentController.Title If Err.Number <> 0 Then - pageTitle = "RouteKit Classic ASP" + pageTitle = "DP Jobs" Err.Clear End If On Error GoTo 0 End If -If Len(pageTitle) = 0 Then pageTitle = "Classic ASP Starter Template" +If Len(pageTitle) = 0 Then pageTitle = "DP Jobs" %> @@ -27,31 +27,21 @@ If Len(pageTitle) = 0 Then pageTitle = "Classic ASP Starter Template" + + + + - - -