From 158436081f7f75fbe6083cb70255f04fdc1d49df Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Wed, 22 Apr 2026 17:09:20 -0400 Subject: [PATCH] the board is working !!! --- agents.md | 533 +++++++++++++++--- app/controllers/BoardsController.asp | 238 ++++++++ app/controllers/CardsController.asp | 162 ++++++ app/controllers/ColumnsController.asp | 150 +++++ app/controllers/SwimLanesController.asp | 150 +++++ app/controllers/autoload_controllers.asp | 14 +- app/models/POBO_board_columns.asp | 53 ++ app/models/POBO_boards.asp | 48 ++ app/models/POBO_cards.asp | 68 +++ app/models/POBO_swim_lanes.asp | 53 ++ app/repositories/board_columns_Repository.asp | 75 +++ app/repositories/boards_Repository.asp | 90 +++ app/repositories/cards_Repository.asp | 104 ++++ app/repositories/swim_lanes_Repository.asp | 75 +++ app/views/Boards/Create.asp | 48 ++ app/views/Boards/Edit.asp | 41 ++ app/views/Boards/Index.asp | 38 ++ app/views/Boards/SettingsPanel.asp | 83 +++ app/views/Boards/Show.asp | 97 ++++ app/views/Cards/_modal.asp | 32 ++ app/views/shared/header.asp | 45 +- applicationhost.config | 2 +- core/lib.ControllerRegistry.asp | 4 + .../20260422100000_create_boards.asp | 24 + .../20260422100001_create_board_columns.asp | 25 + .../20260422100002_create_swim_lanes.asp | 25 + db/migrations/20260422100003_create_cards.asp | 30 + ...22101000_seed_sample_boards_and_layout.asp | 155 +++++ .../20260422101001_seed_sample_cards.asp | 170 ++++++ db/webdata.accdb | Bin 671744 -> 704512 bytes public/Default.asp | 27 + public/css/kanban.css | 357 ++++++++++++ public/css/site.css | 281 +++++++++ public/js/kanban-board.js | 298 ++++++++++ public/js/kanban-modal.js | 174 ++++++ public/js/kanban-settings.js | 235 ++++++++ run_site.cmd | 9 +- skills.md | 40 ++ 38 files changed, 3953 insertions(+), 100 deletions(-) create mode 100644 app/controllers/BoardsController.asp create mode 100644 app/controllers/CardsController.asp create mode 100644 app/controllers/ColumnsController.asp create mode 100644 app/controllers/SwimLanesController.asp create mode 100644 app/models/POBO_board_columns.asp create mode 100644 app/models/POBO_boards.asp create mode 100644 app/models/POBO_cards.asp create mode 100644 app/models/POBO_swim_lanes.asp create mode 100644 app/repositories/board_columns_Repository.asp create mode 100644 app/repositories/boards_Repository.asp create mode 100644 app/repositories/cards_Repository.asp create mode 100644 app/repositories/swim_lanes_Repository.asp create mode 100644 app/views/Boards/Create.asp create mode 100644 app/views/Boards/Edit.asp create mode 100644 app/views/Boards/Index.asp create mode 100644 app/views/Boards/SettingsPanel.asp create mode 100644 app/views/Boards/Show.asp create mode 100644 app/views/Cards/_modal.asp create mode 100644 db/migrations/20260422100000_create_boards.asp create mode 100644 db/migrations/20260422100001_create_board_columns.asp create mode 100644 db/migrations/20260422100002_create_swim_lanes.asp create mode 100644 db/migrations/20260422100003_create_cards.asp create mode 100644 db/migrations/20260422101000_seed_sample_boards_and_layout.asp create mode 100644 db/migrations/20260422101001_seed_sample_cards.asp create mode 100644 public/css/kanban.css create mode 100644 public/css/site.css create mode 100644 public/js/kanban-board.js create mode 100644 public/js/kanban-modal.js create mode 100644 public/js/kanban-settings.js create mode 100644 skills.md diff --git a/agents.md b/agents.md index b1fe094..1826291 100644 --- a/agents.md +++ b/agents.md @@ -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..83a4454 --- /dev/null +++ b/app/controllers/BoardsController.asp @@ -0,0 +1,238 @@ +<% +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.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) & "," & _ + """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.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 + +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..0d03abc --- /dev/null +++ b/app/controllers/CardsController.asp @@ -0,0 +1,162 @@ +<% +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.position = nextPos + card.created_at = Now() + card.created_by = username + card.updated_at = Now() + card.updated_by = username + + cards_Repository().AddNew card + + Response.Write "{""ok"":true,""id"":" & card.id & "," & _ + """job_number"":" & JsonString(card.job_number) & "," & _ + """job_name"":" & JsonString(card.job_name) & "," & _ + """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.updated_at = Now() + card.updated_by = GetCurrentUsername() + + cards_Repository().Update card + + Response.Write "{""ok"":true," & _ + """job_number"":" & JsonString(card.job_number) & "," & _ + """job_name"":" & JsonString(card.job_name) & "}" + 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) + JsonString = """" & Replace(Replace(CStr(s), "\", "\\"), """", "\""") & """" + 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..697080f --- /dev/null +++ b/app/controllers/ColumnsController.asp @@ -0,0 +1,150 @@ +<% +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 parsed : Set parsed = JSON.parse(rawJson) + + If IsNull(parsed) Or IsEmpty(parsed) Then + Response.Write "{""ok"":false,""error"":""Invalid JSON""}" + Exit Sub + End If + + Dim username : username = GetCurrentUsername() + Dim i, item + 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 + Next + + 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/SwimLanesController.asp b/app/controllers/SwimLanesController.asp new file mode 100644 index 0000000..0b0a4ed --- /dev/null +++ b/app/controllers/SwimLanesController.asp @@ -0,0 +1,150 @@ +<% +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 parsed : Set parsed = JSON.parse(rawJson) + + If IsNull(parsed) Or IsEmpty(parsed) Then + Response.Write "{""ok"":false,""error"":""Invalid JSON""}" + Exit Sub + End If + + Dim username : username = GetCurrentUsername() + Dim i, item + 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 + Next + + 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..6e2ba45 --- /dev/null +++ b/app/models/POBO_boards.asp @@ -0,0 +1,48 @@ +<% +Class POBO_boards + Public Properties + + 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 + + Public Property Get slug() : slug = p_slug : End Property + Public Property Let slug(v) : p_slug = 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..619388c --- /dev/null +++ b/app/models/POBO_cards.asp @@ -0,0 +1,68 @@ +<% +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_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_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","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 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..57c229e --- /dev/null +++ b/app/repositories/boards_Repository.asp @@ -0,0 +1,90 @@ +<% +Class boards_Repository_Class + + 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", "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],[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],[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],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?)" + DAL.Execute sql, Array(model.name, model.slug, 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]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" + DAL.Execute sql, Array(model.name, model.slug, 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 + +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..56e3f0c --- /dev/null +++ b/app/repositories/cards_Repository.asp @@ -0,0 +1,104 @@ +<% +Class cards_Repository_Class + + Private Function SelectBase() + SelectBase = "SELECT [id],[board_id],[column_id],[swim_lane_id],[job_number],[job_name],[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 : sql = "INSERT INTO [cards] ([board_id],[column_id],[swim_lane_id],[job_number],[job_name],[position],[created_at],[created_by],[updated_at],[updated_by]) VALUES (?,?,?,?,?,?,?,?,?,?)" + DAL.Execute sql, Array(model.board_id, model.column_id, model.swim_lane_id, model.job_number, model.job_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 [cards] SET [job_number]=?,[job_name]=?,[updated_at]=?,[updated_by]=? WHERE [id]=?" + DAL.Execute sql, Array(model.job_number, model.job_name, model.updated_at, model.updated_by, model.id) + End Sub + + 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 + +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..735ec4a --- /dev/null +++ b/app/views/Boards/Create.asp @@ -0,0 +1,48 @@ +
+
+
+ + + +

New Board

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

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..d7f8e01 --- /dev/null +++ b/app/views/Boards/Show.asp @@ -0,0 +1,97 @@ + +<% +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..f2a8a05 --- /dev/null +++ b/app/views/Cards/_modal.asp @@ -0,0 +1,32 @@ + + diff --git a/app/views/shared/header.asp b/app/views/shared/header.asp index 057b525..6e2e83c 100644 --- a/app/views/shared/header.asp +++ b/app/views/shared/header.asp @@ -27,31 +27,21 @@ If Len(pageTitle) = 0 Then pageTitle = "Classic ASP Starter Template" + + + + - - -