Added announcements.
This commit is contained in:
parent
490806aaee
commit
fec713f15e
|
@ -0,0 +1,5 @@
|
|||
CREATE TABLE announcement (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
type VARCHAR(64) NOT NULL,
|
||||
message VARCHAR(2000) NOT NULL
|
||||
);
|
|
@ -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);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
// }
|
||||
|
|
|
@ -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. -->
|
||||
|
|
|
@ -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}`)
|
||||
}
|
||||
}
|
|
@ -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> {
|
||||
|
|
|
@ -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>
|
|
@ -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?
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
meta {
|
||||
name: Generate Sample Data
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/generate-sample-data
|
||||
body: none
|
||||
auth: none
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
meta {
|
||||
name: Initialize Schema
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/initialize-schema
|
||||
body: none
|
||||
auth: none
|
||||
}
|
Loading…
Reference in New Issue