App
A runnable Geblang example.
- Path
gebweb/examples/feature_tour/src/app.gb- Category
- Feature Tour
Source
module feature_tour.app;
/**
* Feature-tour example: a small task manager that exercises a
* broad slice of the Gebweb surface in one place. Producer-side
* bits live here (controllers, repo, model); consumers (event
* subscribers, scheduled cleanup, mail job) live in `handlers.gb`.
*
* Wiring is in `main.gb`; tests in `tests/tasks_test.gb` drive
* the app through `gebweb.TestClient` and the in-memory backends
* so the suite runs without a real DB / SMTP server.
*/
import gebweb;
import gebweb.app as appmod;
import gebweb.query as q;
import db;
import secrets;
import datetime;
/** Authenticated user attached to each request via the
* API-key authenticator. `role` drives `@RequiresRole`;
* `permissions` drives `@RequiresPermission`. */
export class CurrentUser {
string id;
string name;
string role;
list<string> permissions;
func CurrentUser(string id, string name, string role, list<string> permissions) {
this.id = id;
this.name = name;
this.role = role;
this.permissions = permissions;
}
}
export class Task {
string id;
string ownerId;
string title;
string status; /* "open" | "completed" */
int createdAt;
int completedAt;
string attachmentName;
func Task() {
this.id = "";
this.ownerId = "";
this.title = "";
this.status = "open";
this.createdAt = 0;
this.completedAt = 0;
this.attachmentName = "";
}
}
export class TaskCreateDTO {
string title;
}
/** DB-backed repository. Uses the query DSL (`gebweb.eq`, .gt, etc.)
* for filtered listings and cursor pagination for `/tasks`. */
export class TaskRepo {
db.Connection conn;
func TaskRepo(db.Connection conn) {
this.conn = conn;
ensureSchema(conn);
}
func find(string id): ?Task {
let rows = this.conn.query("SELECT * FROM tasks WHERE id = ?", id).all();
if (rows.length() == 0) { return null; }
return rowToTask(rows[0] as dict<string, any>);
}
func save(Task t): Task {
if (t.id.isEmpty()) {
t.id = "t-" + secrets.randomHex(8);
t.createdAt = datetime.nowUnix();
this.conn.exec(
"INSERT INTO tasks (id, ownerId, title, status, createdAt, completedAt, attachmentName) VALUES (?, ?, ?, ?, ?, ?, ?)",
t.id, t.ownerId, t.title, t.status, t.createdAt, t.completedAt, t.attachmentName
);
} else {
this.conn.exec(
"UPDATE tasks SET title = ?, status = ?, completedAt = ?, attachmentName = ? WHERE id = ?",
t.title, t.status, t.completedAt, t.attachmentName, t.id
);
}
return t;
}
func delete(string id): void {
this.conn.exec("DELETE FROM tasks WHERE id = ?", id);
}
/** Cursor-paginated list. `after` is the createdAt of the last
* item the client saw (empty for first page). */
func listOwned(string ownerId, string status, string after, int size): list<Task> {
let qb = q.Query("tasks").where(q.eq("ownerId", ownerId));
if (!status.isEmpty()) {
qb = qb.where(q.eq("status", status));
}
if (!after.isEmpty()) {
qb = qb.where(q.lt("createdAt", after as int));
}
let sql = qb.orderBy(q.desc("createdAt")).limit(size).select([]);
let rows = this.conn.query(sql["text"] as string, sql["params"] as list<any>).all();
list<Task> out = [];
for (row in rows) {
out = out.push(rowToTask(row as dict<string, any>));
}
return out;
}
/** Used by the scheduled cleanup. Deletes completed tasks
* older than `olderThanUnix`. */
func purgeOldCompleted(int olderThanUnix): int {
let sql = q.Query("tasks")
.where(q.eq("status", "completed"))
.where(q.lt("completedAt", olderThanUnix))
.delete();
let result = this.conn.exec(sql["text"] as string, sql["params"] as list<any>) as dict<string, any>;
return (result["rowsAffected"] ?? 0) as int;
}
}
func ensureSchema(db.Connection conn): void {
conn.exec(
"CREATE TABLE IF NOT EXISTS tasks (" +
"id TEXT PRIMARY KEY, ownerId TEXT NOT NULL, title TEXT NOT NULL, " +
"status TEXT NOT NULL, createdAt INTEGER NOT NULL, completedAt INTEGER NOT NULL, " +
"attachmentName TEXT NOT NULL)"
);
}
func rowToTask(dict<string, any> row): Task {
let t = Task();
t.id = row["id"] as string;
t.ownerId = row["ownerId"] as string;
t.title = row["title"] as string;
t.status = row["status"] as string;
t.createdAt = row["createdAt"] as int;
t.completedAt = row["completedAt"] as int;
t.attachmentName = row["attachmentName"] as string;
return t;
}
/** Task CRUD. @Auth gates everything on a valid API key. The
* list endpoint uses cursor pagination + query DSL. */
@Auth
export class TaskController {
TaskRepo repo;
appmod.GebwebApp app;
func TaskController(TaskRepo repo, appmod.GebwebApp app) {
this.repo = repo;
this.app = app;
}
@Get("/tasks")
@Summary("List the current user's tasks (cursor-paginated)")
func list(CurrentUser user, ?string status, ?string after, ?int size): dict<string, any> {
let s = status == null ? "" : (status as string);
let a = after == null ? "" : (after as string);
let n = size == null ? 25 : (size as int);
let items = this.repo.listOwned(user.id, s, a, n);
string nextCursor = "";
if (items.length() == n) {
nextCursor = items[items.length() - 1].createdAt as string;
}
return {"items": items, "nextCursor": nextCursor};
}
@Post("/tasks")
@Summary("Create a task")
func create(CurrentUser user, TaskCreateDTO body): Task {
let t = Task();
t.ownerId = user.id;
t.title = body.title;
return this.repo.save(t);
}
@Get("/tasks/{id}")
@Summary("Read one of the current user's tasks")
func read(CurrentUser user, string id): Task {
let t = this.repo.find(id);
if (t == null || (t as Task).ownerId != user.id) {
throw gebweb.notFound("task not found");
}
return t as Task;
}
@Post("/tasks/{id}/complete")
@Summary("Mark a task complete; fires task.completed event")
func complete(CurrentUser user, string id): Task {
let existing = this.repo.find(id);
if (existing == null || (existing as Task).ownerId != user.id) {
throw gebweb.notFound("task not found");
}
let t = existing as Task;
t.status = "completed";
t.completedAt = datetime.nowUnix();
let saved = this.repo.save(t);
/* Synchronous in-process event - subscribers in handlers.gb
* decide what to do with it (enqueue mail, log audit, ...). */
gebweb.publish(this.app, "task.completed", {
"taskId": saved.id, "ownerId": saved.ownerId, "title": saved.title, "ownerEmail": user.name + "@example.com"
});
return saved;
}
@Delete("/tasks/{id}")
@RequiresPermission("tasks.delete")
@Summary("Delete a task (requires tasks.delete permission)")
func delete(CurrentUser user, string id): dict<string, any> {
let t = this.repo.find(id);
if (t == null || (t as Task).ownerId != user.id) {
throw gebweb.notFound("task not found");
}
this.repo.delete(id);
return {"deleted": id};
}
}
/** Multipart upload landing in the configured storage backend. */
@Auth
export class AttachmentController {
TaskRepo repo;
appmod.GebwebApp app;
func AttachmentController(TaskRepo repo, appmod.GebwebApp app) {
this.repo = repo;
this.app = app;
}
@Post("/tasks/{id}/attachment")
@Summary("Upload an attachment via gebweb.storage")
func upload(CurrentUser user, string id, dict<string, gebweb.UploadedFile> files): dict<string, any> {
let t = this.repo.find(id);
if (t == null || (t as Task).ownerId != user.id) {
throw gebweb.notFound("task not found");
}
if (!files.contains("file")) {
throw gebweb.badRequest("missing 'file' part");
}
let f = files["file"];
let name = "tasks/" + id + "/" + f.filename;
f.saveToStorage(this.app, name);
let task = t as Task;
task.attachmentName = name;
this.repo.save(task);
return {"name": name, "size": f.size(), "url": gebweb.storageUrl(this.app, name)};
}
}
/** Tiny HTML dashboard built via gebweb.views. */
@Auth
export class DashboardController {
TaskRepo repo;
appmod.GebwebApp app;
func DashboardController(TaskRepo repo, appmod.GebwebApp app) {
this.repo = repo;
this.app = app;
}
@Get("/dashboard")
@Summary("HTML dashboard rendered via gebweb.views")
func index(CurrentUser user): dict<string, any> {
let recent = this.repo.listOwned(user.id, "", "", 5);
return gebweb.html(gebweb.view(this.app, "dashboard.html", {
"user": {"name": user.name, "role": user.role},
"tasks": recent
}));
}
}