Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
6443b67
feat: members table, study table, members controller
Nov 7, 2025
97bdead
Fix: nullable notes for member
Nov 7, 2025
e82e5f6
feat: members controller
Nov 7, 2025
54d1ec2
feat: studies controller
Nov 7, 2025
47a825d
Commissions, Commission Memberships and put actions for updating reso…
Nov 8, 2025
7bc46a0
Patch endpoints for updating a single variable for resources, roles f…
Nov 8, 2025
44fbd73
fix: use correct line endings
Nov 8, 2025
14553c7
Chore: do not auto migrate in program.cs
Nov 8, 2025
e599816
chore: remove unused using
Nov 8, 2025
7b2e2e1
fix: make email and student number unique
Nov 9, 2025
fdea7b1
Update Role.cs
Nov 11, 2025
18964c0
chore: change commission to group
Nov 18, 2025
28644eb
chore: Added group types and active status of groups into controllers…
Nov 19, 2025
59ffa9a
chore: removed 'null\!' in DTO's
Nov 19, 2025
40df172
Single patch endpoint for all controllers
Jan 24, 2026
4a8cd9e
Removed SQL way of thinking with includes to improve performance
Jan 24, 2026
888d64a
Added validation for ROle foreign key when posting group membership
Jan 24, 2026
ec44391
Removed unecessary code in db context
Jan 24, 2026
3899115
Renamed "Group" enum value to Committee
Jan 24, 2026
5515e89
Added more explanation to summary of active variable of a group
Jan 24, 2026
20da1fa
Changed GroupMemberships variable into a viertual ICollection
Jan 24, 2026
19644ee
Change member id to a Guid
Jan 24, 2026
b887802
Change all navigation lists to virtial ICollections
Jan 24, 2026
8408305
DurationYears -> NominalDurationYears
Jan 24, 2026
7b83db9
feat: activities database structure
Jan 25, 2026
a72d82f
Merge branch 'development' into feat/activities
Renspv Feb 6, 2026
61185fd
feat: poster handling
Renspv Feb 6, 2026
8ce214f
feat: poster handling
Renspv Feb 6, 2026
1ea1973
feat: limit file types for poster upload
Renspv Feb 6, 2026
2cd2c9f
fix: line endings
Renspv Feb 7, 2026
8949159
test: lf->clrf
Renspv Feb 9, 2026
c658c62
fix: clrf lf changes
Renspv Feb 9, 2026
f81e24e
fix: clrf lf changes
Renspv Feb 9, 2026
2b17c26
fix: feedback on code review
Renspv Feb 10, 2026
c32d8d2
fix: correct way of posting and putting an activity
Renspv Feb 10, 2026
2307cf3
bitflags for allowedaudience
Renspv Feb 10, 2026
3344415
Migration
Renspv Feb 10, 2026
328ff3e
fix: resolve rebase conflicts
Feb 16, 2026
a4fcb12
add node back to devcontainer
Feb 16, 2026
9f81622
fix: line ending issues
Feb 16, 2026
4a22588
feat: payments
Mar 9, 2026
9cc3fe9
migration
Mar 9, 2026
341bff2
migration + fixing problems with paying
Mar 11, 2026
0b69e00
Permissions in keycloak
Mar 11, 2026
f28a480
adding and deleting members in keycloak, updating user if user info c…
Mar 14, 2026
c53de5e
Merge branch 'feat/payments' into feat/keycloak
Mar 14, 2026
02d5fc8
Return activity by overpaid and unpaid endpoints
Mar 16, 2026
2baed8d
add comments for payment utils
Mar 16, 2026
6c392e6
Merge branch 'feat/payments' into feat/keycloak
Mar 16, 2026
6e9739c
Update keycloak after paying membership
Mar 16, 2026
0c292c2
Keycloak emails
Mar 16, 2026
e635e9d
Suspended and notpaid policies
Mar 16, 2026
e19ba69
Merge branch 'development' into feat/keycloak
Mar 16, 2026
184af1e
done
Mar 16, 2026
5eafb2c
Merge branch 'feat/keycloak' into feat/announcements
Mar 16, 2026
8e1e073
Add language and student number to keycloak token
Mar 17, 2026
c9a8e1e
Keycloak in frontend
Mar 21, 2026
e5d6fb6
fix biome issues
Mar 21, 2026
704199a
move .env, fix cors settings
Mar 21, 2026
e31fd9c
Merge branch 'feat/keycloak' into feat/keycloak-frontend
Mar 21, 2026
0a56c68
move .env, fix cors settings
Mar 21, 2026
68dba2f
Merge branch 'feat/keycloak' into feat/keycloak-frontend
Mar 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .devcontainer/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ services:
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
# Ports will be exposed through the devcontainer.json, trust
network_mode: service:db
env_file:
- .env

db:
image: postgres:17
Expand Down
6 changes: 6 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
"service": "devcontainer",
"workspaceFolder": "/workspaces",

"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
}
},

// Configure tool-specific properties.
"customizations": {
"jetbrains": {
Expand Down
8 changes: 8 additions & 0 deletions .devcontainer/sample.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
HostUrl=http://localhost:5173
KeycloakUrl= # The base url of the keycloak provider
KeycloakRealm= # The name of the keycloak realm
KeycloakClientId= # The client ID of the keycloak client

BackendKeycloakClientId= # The client ID of the keycloak client for the backend authentication
KeycloakClientSecret= # The client secret for the backend authentication
MollieApiKey= # The mollie api key
1 change: 1 addition & 0 deletions Backend/Backend.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

<ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.*" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.20" />
Expand Down
74 changes: 73 additions & 1 deletion Backend/Controllers/Activities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage;
using Backend.Utils;
using Microsoft.AspNetCore.Authorization;

namespace Backend.Controllers
{
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class Activities(PostgresDbContext db) : ControllerBase
{
// GET: api/activities
Expand All @@ -30,12 +32,24 @@ public async Task<ActionResult<IEnumerable<Activity>>> GetActivities()
/// Fetches a single activity.
/// </summary>
/// <param name="id">The id of the activity to fetch.</param>
/// <returns>The full activity.</returns> // TODO: perhaps replace this with a DTO to prevent exposing unneeded fields?
/// <returns>The full activity.</returns>
[HttpGet("{id}")]
public async Task<ActionResult<Activity>> GetActivity(uint id)
{
Guid userId = Guid.Parse(User.Claims.First(c => c.Type == "UserId").Value);

Activity? activity = await db.Activities.FindAsync(id);

if(activity == null)
{
return NotFound();
}

if(activity.DateTimeEnd.UtcDateTime < DateTime.UtcNow && PermissionUtils.IsInGroupInCurrentYear(userId, (uint)PredefinedGroup.Board, db))
{
return Forbid("Only board members can view past activities.");
}

return activity != null ? activity : NotFound();
}

Expand All @@ -48,6 +62,18 @@ public async Task<ActionResult<Activity>> GetActivity(uint id)
[HttpPost]
public async Task<ActionResult<Activity>> PostActivity(PostActivityDTO activityDto)
{
if(activityDto.DateTimeEnd < activityDto.DateTimeStart)
{
return BadRequest("Activity cannot end before it starts.");
}

Guid userId = Guid.Parse(User.Claims.First(c => c.Type == "UserId").Value);

if((activityDto.ShowInKoala || activityDto.ShowOnWebsite) && !PermissionUtils.IsInGroupInCurrentYear(userId, (uint)PredefinedGroup.Board, db))
{
return Forbid("Only board members can create activities for public display.");
}

IDbContextTransaction transaction = await db.Database.BeginTransactionAsync();

try
Expand Down Expand Up @@ -117,8 +143,16 @@ public async Task<ActionResult<Activity>> PostActivity(PostActivityDTO activityD
/// <param name="id">The id of the activity to delete.</param>
/// <returns>Nothing, really.</returns>
[HttpDelete("{id}")]

public async Task<IActionResult> DeleteActivity(uint id)
{
Guid userId = Guid.Parse(User.Claims.First(c => c.Type == "UserId").Value);

if(!PermissionUtils.IsInGroupInCurrentYear(userId, (uint)PredefinedGroup.Board, db))
{
return Forbid("Only board members can delete activities.");
}

Activity? activity = await db.Activities.FindAsync(id);
if (activity == null) return NotFound();

Expand Down Expand Up @@ -150,6 +184,13 @@ public async Task<IActionResult> PatchActivity(uint id, [FromBody] JsonPatchDocu
if (activity == null)
return NotFound();

Guid userId = Guid.Parse(User.Claims.First(c => c.Type == "UserId").Value);

if((activity.ShowInKoala || activity.ShowOnWebsite || patchDoc.Operations.Any(op => op.path == "/showInKoala" || op.path == "/showOnWebsite")) && !PermissionUtils.IsInGroupInCurrentYear(userId, (uint)PredefinedGroup.Board, db))
{
return Forbid("Only board members can update activities for public display.");
}

patchDoc.ApplyTo(activity, ModelState);

if (!ModelState.IsValid)
Expand All @@ -174,6 +215,13 @@ public async Task<IActionResult> UploadPoster(uint id, IFormFile? poster)
var activity = await db.Activities.FindAsync(id);
if (activity == null) return NotFound();

Guid userId = Guid.Parse(User.Claims.First(c => c.Type == "UserId").Value);

if((activity.ShowInKoala || activity.ShowOnWebsite) && !PermissionUtils.IsInGroupInCurrentYear(userId, (uint)PredefinedGroup.Board, db))
{
return Forbid("Only board members can update activity posters for public display.");
}

string? oldPath = activity.PosterPath;

using IDbContextTransaction transaction = await db.Database.BeginTransactionAsync();
Expand Down Expand Up @@ -224,6 +272,18 @@ public async Task<IActionResult> PutActivity(uint id, PostActivityDTO activityDt
Activity? activity = await db.Activities.FindAsync(id);
if (activity == null) return NotFound();

if(activityDto.DateTimeEnd < activityDto.DateTimeStart)
{
return BadRequest("Activity cannot end before it starts.");
}

Guid userId = Guid.Parse(User.Claims.First(c => c.Type == "UserId").Value);

if((activity.ShowInKoala || activity.ShowOnWebsite || activityDto.ShowInKoala || activityDto.ShowOnWebsite) && !PermissionUtils.IsInGroupInCurrentYear(userId, (uint)PredefinedGroup.Board, db))
{
return Forbid("Only board members can update activities for public display.");
}

using IDbContextTransaction transaction = await db.Database.BeginTransactionAsync();

activity.Name = activityDto.Name;
Expand Down Expand Up @@ -309,6 +369,12 @@ public async Task<IActionResult> GetPoster(uint id)
if (activity == null || string.IsNullOrEmpty(activity.PosterPath))
return NotFound("Activity or poster not found.");

Guid userId = Guid.Parse(User.Claims.First(c => c.Type == "UserId").Value);
if (activity.DateTimeEnd.UtcDateTime < DateTime.UtcNow && !PermissionUtils.IsInGroupInCurrentYear(userId, (uint)PredefinedGroup.Board, db))
{
return Forbid("Only board members can view posters of past activities.");
}

var filePath = Path.Combine(Directory.GetCurrentDirectory(), activity.PosterPath);

if (!System.IO.File.Exists(filePath))
Expand All @@ -335,6 +401,12 @@ public async Task<IActionResult> DownloadPoster(uint id)
if (activity == null || string.IsNullOrEmpty(activity.PosterPath))
return NotFound("Activity or poster not found.");

Guid userId = Guid.Parse(User.Claims.First(c => c.Type == "UserId").Value);
if (activity.DateTimeEnd.UtcDateTime < DateTime.UtcNow && !PermissionUtils.IsInGroupInCurrentYear(userId, (uint)PredefinedGroup.Board, db))
{
return Forbid("Only board members can view posters of past activities.");
}

var filePath = Path.Combine(Directory.GetCurrentDirectory(), activity.PosterPath);

if (!System.IO.File.Exists(filePath))
Expand Down
152 changes: 152 additions & 0 deletions Backend/Controllers/Announcements.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
using Backend.Controllers.DTOs;
using Backend.Database;
using Backend.Models;
using Microsoft.AspNetCore.JsonPatch;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;

namespace Backend.Controllers;

[Route("api/[controller]")]
[ApiController]
public class Announcements(PostgresDbContext db) : ControllerBase
{
// GET: api/announcements
/// <summary>
/// Lists all announcements in the database.
/// </summary>
/// <returns>Said list.</returns>
[HttpGet]
public async Task<ActionResult<IEnumerable<Announcement>>> GetAnnouncements(CancellationToken cancellationToken)
{
return await db.Announcements.ToListAsync(cancellationToken);
}

// GET: api/announcements/5
/// <summary>
/// Fetches a single announcement.
/// </summary>
/// <param name="id">The id of the announcement to fetch.</param>
/// <returns>The full announcement.</returns>
[HttpGet("{id}")]
public async Task<ActionResult<Announcement>> GetAnnouncement(uint id, CancellationToken cancellationToken)
{
Announcement? announcement = await db.Announcements.FindAsync(id, cancellationToken);

return announcement != null ? announcement : NotFound();
}

// POST: api/announcements
/// <summary>
/// Creates a new announcement with a unique ID assigned by the database.
/// </summary>
/// <param name="announcementDto">The announcement to be added to the database.</param>
/// <returns>Fully created announcement in body and api route of where to fetch it in the headers.</returns>
[HttpPost]
public async Task<ActionResult<Announcement>> PostAnnouncement(PostAnnouncementDTO announcementDto, CancellationToken cancellationToken)
{
var createdById = uint.Parse(User.Claims.First(c => c.Type == "member_id").Value!);
var newEntry = db.Announcements.Add(new Announcement
{
Title = announcementDto.Title,
Content = announcementDto.Content,
CreatedById = createdById,
CreatedAt = DateTime.UtcNow
});
await db.SaveChangesAsync(cancellationToken);

return CreatedAtAction(nameof(GetAnnouncement), new { id = newEntry.Entity.Id }, newEntry.Entity);
}

// DELETE: api/announcements/5
/// <summary>
/// Deletes an announcement.
/// </summary>
/// <param name="id">The id of the announcement to delete.</param>
/// <returns>Nothing, really.</returns>
/// <remarks>
/// Deleting an announcement will also delete all enrollments and role enrollments associated with said
/// announcement.
/// </remarks>
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteAnnouncement(uint id, CancellationToken cancellationToken)
{
Announcement? announcement = await db.Announcements.FindAsync(id, cancellationToken);
if (announcement == null) return NotFound();

db.Announcements.Remove(announcement);
await db.SaveChangesAsync(cancellationToken);

return NoContent();
}

// PATCH: api/announcements/5/title
/// <summary>
/// Updates an announcement's title.
/// </summary>
/// <param name="id">The id of the announcement to update.</param>
/// <param name="newTitle">The new title of the announcement.</param>
/// <returns>No Content.</returns>
[HttpPatch("{id}/title")]
public async Task<IActionResult> PatchAnnouncementTitle(uint id, [FromBody, StringLength(100)] string newTitle, CancellationToken cancellationToken)
{
Announcement? announcement = await db.Announcements.FindAsync(id, cancellationToken);
if (announcement == null) return NotFound();

announcement.Title = newTitle;
await db.SaveChangesAsync(cancellationToken);

return NoContent();
}

// PATCH: api/announcements/5
/// <summary>
/// Partially updates an announcement's details.
/// </summary>
/// <param name="id">The id of the announcement to update.</param>
/// <param name="patchDoc">The patch document containing the changes.</param>
/// <returns>No Content.</returns>
[HttpPatch("{id}")]
public async Task<IActionResult> PatchAnnouncement(uint id, [FromBody] JsonPatchDocument<Announcement> patchDoc, CancellationToken cancellationToken)
{
if (patchDoc == null)
return BadRequest();

Announcement? announcement = await db.Announcements.FindAsync(new object[] { id }, cancellationToken);
if (announcement == null)
return NotFound();

patchDoc.ApplyTo(announcement, ModelState);

if (!TryValidateModel(announcement))
return BadRequest(ModelState);

if (!ModelState.IsValid)
return BadRequest(ModelState);

await db.SaveChangesAsync(cancellationToken);

return NoContent();
}

// PUT: api/announcements/5
/// <summary>
/// Updates an announcement.
/// </summary>
/// <param name="id">The id of the announcement to update.</param>
/// <param name="announcementDto">The updated announcement data.</param>
/// <returns>No Content.</returns>
[HttpPut("{id}")]
public async Task<IActionResult> PutAnnouncement(uint id, UpdateAnnouncementDTO announcementDto, CancellationToken cancellationToken)
{
Announcement? announcement = await db.Announcements.FindAsync(id, cancellationToken);
if (announcement == null) return NotFound();

announcement.Title = announcementDto.Title;
announcement.Content = announcementDto.Content;
await db.SaveChangesAsync(cancellationToken);

return NoContent();
}
}
25 changes: 25 additions & 0 deletions Backend/Controllers/DTOs/Announcements.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;

namespace Backend.Controllers.DTOs;

public class PostAnnouncementDTO
{
/// <inheritdoc cref="Models.Announcement.Title"/>>
[StringLength(100)]
public required string Title { get; set; }

/// <inheritdoc cref="Models.Announcement.Content"/>>
[StringLength(1000)]
public required string Content { get; set; }
}

public class UpdateAnnouncementDTO
{
/// <inheritdoc cref="Models.Announcement.Title"/>
[StringLength(100)]
public required string Title { get; set; }

/// <inheritdoc cref="Models.Announcement.Content"/>
[StringLength(1000)]
public required string Content { get; set; }
}
Loading
Loading