From fec713f15e8b04f516e972dfaf2d222a943c479e Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Wed, 29 Jan 2025 14:29:59 -0500 Subject: [PATCH] Added announcements. --- api/schema/announcement.sql | 5 + api/source/api_modules/announcement.d | 82 ++++++++++++++++ api/source/app.d | 16 +++ api/source/db.d | 85 +++++++--------- app/src/App.vue | 3 + app/src/api/announcement.ts | 27 ++++++ app/src/api/base.ts | 13 ++- app/src/components/AnnouncementsBanner.vue | 108 +++++++++++++++++++++ app/src/views/AdminDashboardView.vue | 38 ++++++++ bruno-api/Generate Sample Data.bru | 11 +++ bruno-api/Initialize Schema.bru | 11 +++ 11 files changed, 342 insertions(+), 57 deletions(-) create mode 100644 api/schema/announcement.sql create mode 100644 api/source/api_modules/announcement.d create mode 100644 app/src/api/announcement.ts create mode 100644 app/src/components/AnnouncementsBanner.vue create mode 100644 bruno-api/Generate Sample Data.bru create mode 100644 bruno-api/Initialize Schema.bru diff --git a/api/schema/announcement.sql b/api/schema/announcement.sql new file mode 100644 index 0000000..e3b967f --- /dev/null +++ b/api/schema/announcement.sql @@ -0,0 +1,5 @@ +CREATE TABLE announcement ( + id BIGSERIAL PRIMARY KEY, + type VARCHAR(64) NOT NULL, + message VARCHAR(2000) NOT NULL +); diff --git a/api/source/api_modules/announcement.d b/api/source/api_modules/announcement.d new file mode 100644 index 0000000..432e1bb --- /dev/null +++ b/api/source/api_modules/announcement.d @@ -0,0 +1,82 @@ +module api_modules.announcement; + +import handy_httpd; +import handy_httpd.handlers.path_handler; +import slf4d; +import ddbc; + +import db; +import data_utils; +import api_modules.auth : getAdminUserOrThrow; + +struct Announcement { + const ulong id; + const string type; + const string message; + + static Announcement parse(DataSetReader r) { + return Announcement( + r.getUlong(1), + r.getString(2), + r.getString(3) + ); + } +} + +void registerApiEndpoints(PathHandler handler) { + handler.addMapping(Method.GET, "api/announcement", &getAnnouncementsEndpoint); + handler.addMapping(Method.POST, "api/announcement", &createAnnouncementAdminEndpoint); + handler.addMapping(Method.DELETE, "api/announcement/:id:ulong", &deleteAnnouncementAdminEndpoint); +} + +void getAnnouncementsEndpoint(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + const announcements = findAll( + conn, + "SELECT * FROM announcement ORDER BY id DESC", + &Announcement.parse + ); + writeJsonBody(ctx, announcements); +} + +void createAnnouncementAdminEndpoint(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + auto user = getAdminUserOrThrow(ctx, conn); + struct Payload { + string type; + string message; + } + Payload payload = readJsonPayload!(Payload)(ctx); + if (payload.type is null || (payload.type != "INFO" && payload.type != "ERROR")) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Invalid type."); + return; + } + if (payload.message is null || payload.message.length < 1 || payload.message.length > 2000) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Invalid message."); + return; + } + ulong id = insertOne( + conn, + "INSERT INTO announcement (type, message) VALUES (?, ?) RETURNING id", + payload.type, payload.message + ); + const announcement = findOne( + conn, + "SELECT * FROM announcement WHERE id = ?", + &Announcement.parse, + id + ).orElseThrow(); + writeJsonBody(ctx, announcement); +} + +void deleteAnnouncementAdminEndpoint(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + auto user = getAdminUserOrThrow(ctx, conn); + ulong id = ctx.request.getPathParamAs!ulong("id"); + update(conn, "DELETE FROM announcement WHERE id = ?", id); +} diff --git a/api/source/app.d b/api/source/app.d index ea667ab..c233c56 100644 --- a/api/source/app.d +++ b/api/source/app.d @@ -3,6 +3,7 @@ import handy_httpd.handlers.path_handler; import std.process; static import api_modules.auth; +static import api_modules.announcement; static import api_modules.classroom_compliance.api; void main() { @@ -25,7 +26,12 @@ void main() { PathHandler handler = new PathHandler(); handler.addMapping(Method.OPTIONS, "/api/**", &optionsEndpoint); + if (env == "DEV") { + handler.addMapping("/api/initialize-schema", &initializeSchemaEndpoint); + handler.addMapping("/api/generate-sample-data", &sampleDataEndpoint); + } api_modules.auth.registerApiEndpoints(handler); + api_modules.announcement.registerApiEndpoints(handler); api_modules.classroom_compliance.api.registerApiEndpoints(handler); HttpServer server = new HttpServer(handler, config); @@ -35,3 +41,13 @@ void main() { void optionsEndpoint(ref HttpRequestContext ctx) { ctx.response.status = HttpStatus.OK; } + +void initializeSchemaEndpoint(ref HttpRequestContext ctx) { + import db : initializeSchema; + initializeSchema(); +} + +void sampleDataEndpoint(ref HttpRequestContext ctx) { + import sample_data : insertSampleData; + insertSampleData(); +} diff --git a/api/source/db.d b/api/source/db.d index 9daa665..123cef5 100644 --- a/api/source/db.d +++ b/api/source/db.d @@ -4,10 +4,12 @@ import std.algorithm; import std.array; import std.typecons; import std.conv; +import std.string : split, strip; import ddbc; import slf4d; import handy_httpd.components.optional; +import handy_httpd; private DataSource dataSource; @@ -25,6 +27,37 @@ Connection getDb() { return dataSource.getConnection(); } +void initializeSchema() { + info("Initializing database schema."); + Connection conn = getDb(); + scope(exit) conn.close(); + Statement stmt = conn.createStatement(); + scope(exit) stmt.close(); + const string AUTH_SCHEMA = import("schema/auth.sql"); + const string CLASSROOM_COMPLIANCE_SCHEMA = import("schema/classroom_compliance.sql"); + const schemas = [AUTH_SCHEMA, CLASSROOM_COMPLIANCE_SCHEMA]; + uint schemaNumber = 1; + foreach (schema; schemas) { + infoF!"Intializing schema #%d."(schemaNumber++); + auto statements = schema.split(";") + .map!(s => strip(s)) + .filter!(s => s.length > 0); + uint stmtNumber = 1; + foreach (statementStr; statements) { + infoF!"Executing statement #%d."(stmtNumber++); + try { + stmt.executeUpdate(statementStr); + } catch (SQLException e) { + error(e, "Failed to execute schema statement."); + throw new HttpStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Failed to initialize schema. See logs for more info." + ); + } + } + } +} + T[] findAll(T, Args...)( Connection conn, string query, @@ -111,55 +144,3 @@ void bindAllArgs(Args...)(PreparedStatement ps, Args args) { else static assert(false, "Unsupported argument type: " ~ (typeof(arg).stringof)); } } - -private string toSnakeCase(string camelCase) { - import std.uni; - if (camelCase.length == 0) return camelCase; - auto app = appender!string; - app ~= toLower(camelCase[0]); - for (int i = 1; i < camelCase.length; i++) { - if (isUpper(camelCase[i])) { - app ~= '_'; - app ~= toLower(camelCase[i]); - } else { - app ~= camelCase[i]; - } - } - return app[]; -} - -unittest { - assert(toSnakeCase("testValue") == "test_value"); -} - -private string[] getColumnNames(T)() { - import std.string : toLower; - alias members = __traits(allMembers, T); - string[members.length] columnNames; - static foreach (i; 0 .. members.length) { - static if (__traits(getAttributes, __traits(getMember, T, members[i])).length > 0) { - columnNames[i] = toLower(__traits(getAttributes, __traits(getMember, T, members[i]))[0].name); - } else { - columnNames[i] = toLower(toSnakeCase(members[i])); - } - } - return columnNames.dup; -} - -private string getArgsStr(T)() { - import std.traits : Fields; - alias types = Fields!T; - string argsStr = ""; - static foreach (i, type; types) { - argsStr ~= "row.peek!(" ~ type.stringof ~ ")(" ~ i.to!string ~ ")"; - static if (i + 1 < types.length) { - argsStr ~= ", "; - } - } - return argsStr; -} - -// T parseRow(T)(Row row) { -// mixin("T t = T(" ~ getArgsStr!T ~ ");"); -// return t; -// } diff --git a/app/src/App.vue b/app/src/App.vue index 04f33a9..11ddaac 100644 --- a/app/src/App.vue +++ b/app/src/App.vue @@ -2,6 +2,7 @@ import { RouterLink, RouterView, useRouter } from 'vue-router' import { useAuthStore } from './stores/auth' import AlertDialog from './components/AlertDialog.vue' +import AnnouncementsBanner from './components/AnnouncementsBanner.vue' const authStore = useAuthStore() const router = useRouter() @@ -33,6 +34,8 @@ async function logOut() { + + diff --git a/app/src/api/announcement.ts b/app/src/api/announcement.ts new file mode 100644 index 0000000..b1dbf55 --- /dev/null +++ b/app/src/api/announcement.ts @@ -0,0 +1,27 @@ +import { APIClient, APIResponse, type AuthStoreType } from './base' + +const BASE_URL = import.meta.env.VITE_API_URL + '/announcement' + +export interface Announcement { + id: number + type: string + message: string +} + +export class AnnouncementAPIClient extends APIClient { + constructor(authStore: AuthStoreType | null) { + super(BASE_URL, authStore) + } + + getAnnouncements(): APIResponse { + return super.get('') + } + + createAnnouncement(type: string, message: string): APIResponse { + return super.post('', { type: type, message: message }) + } + + deleteAnnouncement(id: number): APIResponse { + return super.delete(`/${id}`) + } +} diff --git a/app/src/api/base.ts b/app/src/api/base.ts index 7ac2455..16014bd 100644 --- a/app/src/api/base.ts +++ b/app/src/api/base.ts @@ -47,8 +47,8 @@ export type AuthStoreType = ReturnType export abstract class APIClient { readonly baseUrl: string - authStore: AuthStoreType - constructor(baseUrl: string, authStore: AuthStoreType) { + authStore: AuthStoreType | null + constructor(baseUrl: string, authStore: AuthStoreType | null) { this.baseUrl = baseUrl this.authStore = authStore } @@ -93,10 +93,13 @@ export abstract class APIClient { return new APIResponse(this.handleAPIResponseWithNoBody(promise)) } - protected getAuthHeaders() { - return { - Authorization: 'Basic ' + this.authStore.getBasicAuth(), + protected getAuthHeaders(): HeadersInit { + if (this.authStore !== null && this.authStore.state) { + return { + Authorization: 'Basic ' + this.authStore.getBasicAuth(), + } } + return {} } protected async handleAPIResponse(promise: Promise): Promise { diff --git a/app/src/components/AnnouncementsBanner.vue b/app/src/components/AnnouncementsBanner.vue new file mode 100644 index 0000000..aa6ea61 --- /dev/null +++ b/app/src/components/AnnouncementsBanner.vue @@ -0,0 +1,108 @@ + + + diff --git a/app/src/views/AdminDashboardView.vue b/app/src/views/AdminDashboardView.vue index d7c7339..db9bec9 100644 --- a/app/src/views/AdminDashboardView.vue +++ b/app/src/views/AdminDashboardView.vue @@ -1,4 +1,6 @@