Added announcements.
Build and Test App / Build-and-test-App (push) Successful in 33s Details
Build and Test API / Build-and-test-API (push) Successful in 52s Details

This commit is contained in:
Andrew Lalis 2025-01-29 14:29:59 -05:00
parent 490806aaee
commit fec713f15e
11 changed files with 342 additions and 57 deletions

View File

@ -0,0 +1,5 @@
CREATE TABLE announcement (
id BIGSERIAL PRIMARY KEY,
type VARCHAR(64) NOT NULL,
message VARCHAR(2000) NOT NULL
);

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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;
// }

View File

@ -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() {
</div>
</header>
<AnnouncementsBanner />
<RouterView />
<!-- Global dialog elements are included here below, hidden by default. -->

View File

@ -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<Announcement[]> {
return super.get('')
}
createAnnouncement(type: string, message: string): APIResponse<Announcement> {
return super.post('', { type: type, message: message })
}
deleteAnnouncement(id: number): APIResponse<void> {
return super.delete(`/${id}`)
}
}

View File

@ -47,8 +47,8 @@ export type AuthStoreType = ReturnType<typeof useAuthStore>
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<T>(promise: Promise<Response>): Promise<T | APIError> {

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import { AnnouncementAPIClient, type Announcement } from '@/api/announcement';
import { APIError } from '@/api/base';
import { useAuthStore } from '@/stores/auth';
import { onMounted, onUnmounted, ref, useTemplateRef, type Ref } from 'vue';
import ConfirmDialog from './ConfirmDialog.vue';
const REFRESH_INTERVAL_MS = 5000
const authStore = useAuthStore()
const client = new AnnouncementAPIClient(authStore)
const announcements: Ref<Announcement[]> = ref([])
const dismissedIds: Ref<number[]> = ref([])
const refreshTimerId: Ref<number | undefined> = ref(undefined)
const deleteAnnouncementConfirmDialog = useTemplateRef('deleteAnnouncementConfirmDialog')
onMounted(() => {
refreshAnnouncements()
refreshTimerId.value = setInterval(refreshAnnouncements, REFRESH_INTERVAL_MS)
})
onUnmounted(() => clearInterval(refreshTimerId.value))
function refreshAnnouncements() {
client.getAnnouncements().result.then(value => {
if (value instanceof APIError) {
console.warn('Failed to get announcements: ', value.message)
announcements.value = []
} else {
announcements.value = value
}
})
}
function getVisibleAnnouncements(): Announcement[] {
return announcements.value.filter(a => !dismissedIds.value.includes(a.id))
}
function getBannerClasses(a: Announcement) {
return {
'announcement-banner-info': a.type === 'INFO',
'announcement-banner-error': a.type === 'ERROR'
}
}
function dismiss(a: Announcement) {
dismissedIds.value.push(a.id)
}
async function deleteAnnouncement(a: Announcement) {
const result = await deleteAnnouncementConfirmDialog.value?.show()
if (result) {
await client.deleteAnnouncement(a.id).handleErrorsWithAlert()
}
}
</script>
<template>
<div>
<div v-for="a in getVisibleAnnouncements()" :key="a.id" class="announcement-banner" :class="getBannerClasses(a)">
<p class="announcement-banner-message">{{ a.message }}</p>
<button class="announcement-banner-button" @click="dismiss(a)">Dismiss</button>
<button v-if="authStore.admin" class="announcement-banner-button" @click="deleteAnnouncement(a)">Delete</button>
</div>
<ConfirmDialog ref="deleteAnnouncementConfirmDialog">
<p>
Are you sure you want to delete this announcement? It will take a few
seconds for the announcement to disappear from users' screens.
</p>
</ConfirmDialog>
</div>
</template>
<style scoped>
.announcement-banner {
padding: 1rem;
margin: 1rem 1rem 0 1rem;
border-radius: 15px;
border-width: 2px;
border-style: solid;
display: flex;
}
.announcement-banner-message {
margin: 0;
flex-grow: 1;
}
.announcement-banner-button {
background-color: inherit;
border: none;
font-size: small;
color: rgb(207, 207, 207);
}
.announcement-banner-button:hover {
color: white;
cursor: pointer;
}
.announcement-banner-info {
background-color: rgb(19, 19, 85);
border-color: rgb(70, 70, 201);
}
.announcement-banner-error {
background-color: rgb(146, 14, 14);
border-color: rgb(228, 110, 106);
}
</style>

View File

@ -1,4 +1,6 @@
<script setup lang="ts">
import { showAlert } from '@/alerts';
import { AnnouncementAPIClient } from '@/api/announcement';
import { AuthenticationAPIClient, type User, type UserUpdatePayload } from '@/api/auth';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { useAuthStore } from '@/stores/auth';
@ -13,6 +15,12 @@ interface CreateUserData {
}
const createUserFormData: Ref<CreateUserData> = ref({ username: '', password: '' })
interface CreateAnnouncementFormData {
type: string
message: string
}
const createAnnouncementFormData: Ref<CreateAnnouncementFormData> = ref({ type: 'INFO', message: '' })
const users: Ref<User[]> = ref([])
const usersPage: Ref<number> = ref(0)
const usersPageSize: Ref<number> = ref(50)
@ -57,6 +65,17 @@ async function doCreateUser() {
fetchUsers();
}
}
async function doCreateAnnouncement() {
const client = new AnnouncementAPIClient(authStore)
const a = await client.createAnnouncement(
createAnnouncementFormData.value.type, createAnnouncementFormData.value.message
).handleErrorsWithAlert()
if (a !== null) {
await showAlert('Created announcement. It will appear shortly.')
createAnnouncementFormData.value = { type: 'INFO', message: '' }
}
}
</script>
<template>
<main>
@ -122,6 +141,25 @@ async function doCreateUser() {
</div>
</form>
<form @submit.prevent="doCreateAnnouncement">
<h3>Create Announcement</h3>
<div>
<label for="create-announcement-type">Type</label>
<select id="create-announcement-type" v-model="createAnnouncementFormData.type">
<option value="INFO" selected>INFO</option>
<option value="ERROR">ERROR</option>
</select>
</div>
<div>
<label for="create-announcement-message">Message</label>
<textarea id="create-announcement-message" v-model="createAnnouncementFormData.message" maxlength="2000"
minlength="1" required style="min-width: 300px; min-height: 100px;"></textarea>
</div>
<div>
<button type="submit">Create</button>
</div>
</form>
<ConfirmDialog ref="deleteUserConfirmDialog">
<p>
Are you sure you want to delete this user?

View File

@ -0,0 +1,11 @@
meta {
name: Generate Sample Data
type: http
seq: 3
}
get {
url: {{base_url}}/generate-sample-data
body: none
auth: none
}

View File

@ -0,0 +1,11 @@
meta {
name: Initialize Schema
type: http
seq: 2
}
get {
url: {{base_url}}/initialize-schema
body: none
auth: none
}