feat(sprint7): Phase 2 — Info Board (Schwarzes Brett)
Backend: - V13 Flyway migration: info_board_posts, post_attachments, post_read_status tables - InfoBoardPost entity with category enum (EVENT, RULE, GENERAL, MAINTENANCE) - PostAttachment entity (table created, upload deferred to later) - PostReadStatus entity with composite key (post_id, member_id) - InfoBoardPostRepository with paginated queries + unread count - InfoBoardService: CRUD, pin/archive, mark-as-read, notification dispatch - InfoBoardController: admin CRUD + portal read/unread endpoints - Integration with NotificationService and AuditService Frontend: - info-board.ts service with React Query hooks for all endpoints - Admin Info Board page at /info-board with create dialog, filters, pin/archive/delete - Navigation: added 'Schwarzes Brett' to admin sidebar - i18n: added infoBoard.* keys to de.json and en.json - Fixed pre-existing prettier issues in notification-compose.ts - Fixed BufferSource type issue in push-subscription.ts
@@ -0,0 +1,213 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.InfoBoardPost;
|
||||||
|
import de.cannamanage.domain.enums.InfoBoardCategory;
|
||||||
|
import de.cannamanage.service.InfoBoardService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Info Board (Schwarzes Brett) endpoints for admin and portal.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InfoBoardController {
|
||||||
|
|
||||||
|
private final InfoBoardService infoBoardService;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ADMIN ENDPOINTS (require MANAGE_INFO_BOARD permission)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new info board post.
|
||||||
|
*/
|
||||||
|
@PostMapping("/api/v1/info-board")
|
||||||
|
public ResponseEntity<?> createPost(
|
||||||
|
@Valid @RequestBody CreatePostRequest request,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
|
||||||
|
UUID authorId = UUID.fromString(user.getUsername());
|
||||||
|
InfoBoardPost post = infoBoardService.createPost(
|
||||||
|
request.clubId(), request.title(), request.content(),
|
||||||
|
request.category(), request.pinned() != null && request.pinned(), authorId);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(toResponse(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List posts (admin view with optional filters).
|
||||||
|
*/
|
||||||
|
@GetMapping("/api/v1/info-board")
|
||||||
|
public ResponseEntity<?> listPosts(
|
||||||
|
@RequestParam UUID clubId,
|
||||||
|
@RequestParam(required = false) InfoBoardCategory category,
|
||||||
|
@RequestParam(defaultValue = "false") boolean includeArchived,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
|
||||||
|
Page<InfoBoardPost> posts = infoBoardService.getPosts(clubId, category, includeArchived, page, size);
|
||||||
|
var items = posts.getContent().stream().map(this::toResponse).toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"posts", items,
|
||||||
|
"totalElements", posts.getTotalElements(),
|
||||||
|
"totalPages", posts.getTotalPages(),
|
||||||
|
"page", posts.getNumber()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single post.
|
||||||
|
*/
|
||||||
|
@GetMapping("/api/v1/info-board/{id}")
|
||||||
|
public ResponseEntity<?> getPost(@PathVariable UUID id) {
|
||||||
|
InfoBoardPost post = infoBoardService.getPost(id);
|
||||||
|
return ResponseEntity.ok(toResponse(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a post.
|
||||||
|
*/
|
||||||
|
@PutMapping("/api/v1/info-board/{id}")
|
||||||
|
public ResponseEntity<?> updatePost(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody UpdatePostRequest request) {
|
||||||
|
|
||||||
|
InfoBoardPost post = infoBoardService.updatePost(
|
||||||
|
id, request.title(), request.content(), request.category(), request.pinned());
|
||||||
|
return ResponseEntity.ok(toResponse(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a post.
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/api/v1/info-board/{id}")
|
||||||
|
public ResponseEntity<?> deletePost(@PathVariable UUID id) {
|
||||||
|
infoBoardService.deletePost(id);
|
||||||
|
return ResponseEntity.ok(Map.of("deleted", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive a post.
|
||||||
|
*/
|
||||||
|
@PostMapping("/api/v1/info-board/{id}/archive")
|
||||||
|
public ResponseEntity<?> archivePost(@PathVariable UUID id) {
|
||||||
|
InfoBoardPost post = infoBoardService.archivePost(id);
|
||||||
|
return ResponseEntity.ok(toResponse(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unarchive a post.
|
||||||
|
*/
|
||||||
|
@PostMapping("/api/v1/info-board/{id}/unarchive")
|
||||||
|
public ResponseEntity<?> unarchivePost(@PathVariable UUID id) {
|
||||||
|
InfoBoardPost post = infoBoardService.unarchivePost(id);
|
||||||
|
return ResponseEntity.ok(toResponse(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle pin status.
|
||||||
|
*/
|
||||||
|
@PostMapping("/api/v1/info-board/{id}/pin")
|
||||||
|
public ResponseEntity<?> togglePin(@PathVariable UUID id) {
|
||||||
|
InfoBoardPost post = infoBoardService.togglePin(id);
|
||||||
|
return ResponseEntity.ok(toResponse(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PORTAL ENDPOINTS (member access)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get posts for the member's club (non-archived, pinned first).
|
||||||
|
*/
|
||||||
|
@GetMapping("/api/v1/portal/info-board")
|
||||||
|
public ResponseEntity<?> getPortalPosts(
|
||||||
|
@RequestParam UUID clubId,
|
||||||
|
@RequestParam(required = false) InfoBoardCategory category,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
|
||||||
|
Page<InfoBoardPost> posts = infoBoardService.getPosts(clubId, category, false, page, size);
|
||||||
|
var items = posts.getContent().stream().map(this::toResponse).toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"posts", items,
|
||||||
|
"totalElements", posts.getTotalElements(),
|
||||||
|
"totalPages", posts.getTotalPages(),
|
||||||
|
"page", posts.getNumber()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a post as read.
|
||||||
|
*/
|
||||||
|
@PostMapping("/api/v1/portal/info-board/{id}/read")
|
||||||
|
public ResponseEntity<?> markAsRead(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@RequestParam UUID memberId) {
|
||||||
|
infoBoardService.markAsRead(id, memberId);
|
||||||
|
return ResponseEntity.ok(Map.of("read", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread post count for badge display.
|
||||||
|
*/
|
||||||
|
@GetMapping("/api/v1/portal/info-board/unread-count")
|
||||||
|
public ResponseEntity<?> getUnreadCount(
|
||||||
|
@RequestParam UUID clubId,
|
||||||
|
@RequestParam UUID memberId) {
|
||||||
|
long count = infoBoardService.getUnreadCount(clubId, memberId);
|
||||||
|
return ResponseEntity.ok(Map.of("unreadCount", count));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DTOs
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
public record CreatePostRequest(
|
||||||
|
@NotNull UUID clubId,
|
||||||
|
@NotBlank @Size(max = 200) String title,
|
||||||
|
@NotBlank String content,
|
||||||
|
@NotNull InfoBoardCategory category,
|
||||||
|
Boolean pinned
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record UpdatePostRequest(
|
||||||
|
@Size(max = 200) String title,
|
||||||
|
String content,
|
||||||
|
InfoBoardCategory category,
|
||||||
|
Boolean pinned
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Response mapping
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private Map<String, Object> toResponse(InfoBoardPost post) {
|
||||||
|
return Map.of(
|
||||||
|
"id", post.getId(),
|
||||||
|
"clubId", post.getClubId(),
|
||||||
|
"title", post.getTitle(),
|
||||||
|
"content", post.getContent(),
|
||||||
|
"category", post.getCategory().name(),
|
||||||
|
"pinned", post.isPinned(),
|
||||||
|
"archived", post.isArchived(),
|
||||||
|
"authorId", post.getAuthorId(),
|
||||||
|
"createdAt", post.getCreatedAt().toString(),
|
||||||
|
"updatedAt", post.getUpdatedAt() != null ? post.getUpdatedAt().toString() : ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- V13: Info Board (Schwarzes Brett) tables
|
||||||
|
|
||||||
|
CREATE TABLE info_board_posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
category VARCHAR(50) NOT NULL,
|
||||||
|
is_pinned BOOLEAN DEFAULT FALSE,
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE,
|
||||||
|
author_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE post_attachments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
post_id UUID NOT NULL REFERENCES info_board_posts(id) ON DELETE CASCADE,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
content_type VARCHAR(100),
|
||||||
|
file_size BIGINT,
|
||||||
|
storage_path VARCHAR(500) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE post_read_status (
|
||||||
|
post_id UUID NOT NULL REFERENCES info_board_posts(id) ON DELETE CASCADE,
|
||||||
|
member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||||
|
read_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (post_id, member_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_info_board_posts_club_id ON info_board_posts(club_id);
|
||||||
|
CREATE INDEX idx_info_board_posts_category ON info_board_posts(category);
|
||||||
|
CREATE INDEX idx_info_board_posts_pinned ON info_board_posts(is_pinned) WHERE is_pinned = TRUE;
|
||||||
|
CREATE INDEX idx_info_board_posts_tenant ON info_board_posts(tenant_id);
|
||||||
|
CREATE INDEX idx_post_attachments_post_id ON post_attachments(post_id);
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.InfoBoardCategory;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Info board post entity — club-scoped announcements (Schwarzes Brett).
|
||||||
|
* Content is stored as HTML (from Tiptap rich text editor).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "info_board_posts", indexes = {
|
||||||
|
@Index(name = "idx_info_board_posts_club_id", columnList = "club_id"),
|
||||||
|
@Index(name = "idx_info_board_posts_category", columnList = "category"),
|
||||||
|
@Index(name = "idx_info_board_posts_tenant", columnList = "tenant_id")
|
||||||
|
})
|
||||||
|
public class InfoBoardPost extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "club_id", nullable = false)
|
||||||
|
private UUID clubId;
|
||||||
|
|
||||||
|
@Column(name = "title", nullable = false, length = 200)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "category", nullable = false, length = 50)
|
||||||
|
private InfoBoardCategory category;
|
||||||
|
|
||||||
|
@Column(name = "is_pinned", nullable = false)
|
||||||
|
private boolean pinned = false;
|
||||||
|
|
||||||
|
@Column(name = "is_archived", nullable = false)
|
||||||
|
private boolean archived = false;
|
||||||
|
|
||||||
|
@Column(name = "author_id", nullable = false)
|
||||||
|
private UUID authorId;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
private List<PostAttachment> attachments = new ArrayList<>();
|
||||||
|
|
||||||
|
public InfoBoardPost() {}
|
||||||
|
|
||||||
|
public InfoBoardPost(UUID clubId, String title, String content, InfoBoardCategory category, UUID authorId) {
|
||||||
|
this.clubId = clubId;
|
||||||
|
this.title = title;
|
||||||
|
this.content = content;
|
||||||
|
this.category = category;
|
||||||
|
this.authorId = authorId;
|
||||||
|
this.updatedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
@Override
|
||||||
|
void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
if (this.updatedAt == null) {
|
||||||
|
this.updatedAt = Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
void onUpdate() {
|
||||||
|
this.updatedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public UUID getClubId() { return clubId; }
|
||||||
|
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||||
|
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
|
||||||
|
public String getContent() { return content; }
|
||||||
|
public void setContent(String content) { this.content = content; }
|
||||||
|
|
||||||
|
public InfoBoardCategory getCategory() { return category; }
|
||||||
|
public void setCategory(InfoBoardCategory category) { this.category = category; }
|
||||||
|
|
||||||
|
public boolean isPinned() { return pinned; }
|
||||||
|
public void setPinned(boolean pinned) { this.pinned = pinned; }
|
||||||
|
|
||||||
|
public boolean isArchived() { return archived; }
|
||||||
|
public void setArchived(boolean archived) { this.archived = archived; }
|
||||||
|
|
||||||
|
public UUID getAuthorId() { return authorId; }
|
||||||
|
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
|
||||||
|
|
||||||
|
public Instant getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
|
||||||
|
public List<PostAttachment> getAttachments() { return attachments; }
|
||||||
|
public void setAttachments(List<PostAttachment> attachments) { this.attachments = attachments; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File attachment for an info board post.
|
||||||
|
* MVP: table created but upload not yet implemented.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "post_attachments", indexes = {
|
||||||
|
@Index(name = "idx_post_attachments_post_id", columnList = "post_id")
|
||||||
|
})
|
||||||
|
public class PostAttachment extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "post_id", nullable = false)
|
||||||
|
private InfoBoardPost post;
|
||||||
|
|
||||||
|
@Column(name = "filename", nullable = false, length = 255)
|
||||||
|
private String filename;
|
||||||
|
|
||||||
|
@Column(name = "content_type", length = 100)
|
||||||
|
private String contentType;
|
||||||
|
|
||||||
|
@Column(name = "file_size")
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
@Column(name = "storage_path", nullable = false, length = 500)
|
||||||
|
private String storagePath;
|
||||||
|
|
||||||
|
public PostAttachment() {}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public InfoBoardPost getPost() { return post; }
|
||||||
|
public void setPost(InfoBoardPost post) { this.post = post; }
|
||||||
|
|
||||||
|
public String getFilename() { return filename; }
|
||||||
|
public void setFilename(String filename) { this.filename = filename; }
|
||||||
|
|
||||||
|
public String getContentType() { return contentType; }
|
||||||
|
public void setContentType(String contentType) { this.contentType = contentType; }
|
||||||
|
|
||||||
|
public Long getFileSize() { return fileSize; }
|
||||||
|
public void setFileSize(Long fileSize) { this.fileSize = fileSize; }
|
||||||
|
|
||||||
|
public String getStoragePath() { return storagePath; }
|
||||||
|
public void setStoragePath(String storagePath) { this.storagePath = storagePath; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks which members have read which info board posts.
|
||||||
|
* Composite primary key: (post_id, member_id).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "post_read_status")
|
||||||
|
@IdClass(PostReadStatus.PostReadStatusId.class)
|
||||||
|
public class PostReadStatus {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "post_id", nullable = false)
|
||||||
|
private UUID postId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "member_id", nullable = false)
|
||||||
|
private UUID memberId;
|
||||||
|
|
||||||
|
@Column(name = "read_at", nullable = false)
|
||||||
|
private Instant readAt;
|
||||||
|
|
||||||
|
public PostReadStatus() {}
|
||||||
|
|
||||||
|
public PostReadStatus(UUID postId, UUID memberId) {
|
||||||
|
this.postId = postId;
|
||||||
|
this.memberId = memberId;
|
||||||
|
this.readAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public UUID getPostId() { return postId; }
|
||||||
|
public void setPostId(UUID postId) { this.postId = postId; }
|
||||||
|
|
||||||
|
public UUID getMemberId() { return memberId; }
|
||||||
|
public void setMemberId(UUID memberId) { this.memberId = memberId; }
|
||||||
|
|
||||||
|
public Instant getReadAt() { return readAt; }
|
||||||
|
public void setReadAt(Instant readAt) { this.readAt = readAt; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite key class for PostReadStatus.
|
||||||
|
*/
|
||||||
|
public static class PostReadStatusId implements Serializable {
|
||||||
|
private UUID postId;
|
||||||
|
private UUID memberId;
|
||||||
|
|
||||||
|
public PostReadStatusId() {}
|
||||||
|
|
||||||
|
public PostReadStatusId(UUID postId, UUID memberId) {
|
||||||
|
this.postId = postId;
|
||||||
|
this.memberId = memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (o == null || getClass() != o.getClass()) return false;
|
||||||
|
PostReadStatusId that = (PostReadStatusId) o;
|
||||||
|
return Objects.equals(postId, that.postId) && Objects.equals(memberId, that.memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(postId, memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categories for info board posts.
|
||||||
|
*/
|
||||||
|
public enum InfoBoardCategory {
|
||||||
|
EVENT,
|
||||||
|
RULE,
|
||||||
|
GENERAL,
|
||||||
|
MAINTENANCE
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 63 KiB |
@@ -1,77 +1,78 @@
|
|||||||
# CannaManage — Visual Tour (Sprint 4)
|
# CannaManage — Visual Tour (Sprint 4)
|
||||||
|
|
||||||
**Generated:** 2026-06-12
|
**Generated:** 2026-06-13
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Admin Login
|
## Admin Login
|
||||||
|
|
||||||
| Dark Mode | Light Mode |
|
| Dark Mode | Light Mode |
|
||||||
| -------------------------------------------------- | ---------------------------------------------------- |
|
|-----------|------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Member Portal Login
|
## Member Portal Login
|
||||||
|
|
||||||
| Dark Mode | Light Mode |
|
| Dark Mode | Light Mode |
|
||||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------- |
|
|-----------|------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Club Dashboard (auth required)
|
## Club Dashboard (auth required)
|
||||||
|
|
||||||
| Dark Mode | Light Mode |
|
| Dark Mode | Light Mode |
|
||||||
| ------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
|-----------|------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Member Management (auth required)
|
## Member Management (auth required)
|
||||||
|
|
||||||
| Dark Mode | Light Mode |
|
| Dark Mode | Light Mode |
|
||||||
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|-----------|------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Distribution History (auth required)
|
## Distribution History (auth required)
|
||||||
|
|
||||||
| Dark Mode | Light Mode |
|
| Dark Mode | Light Mode |
|
||||||
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|-----------|------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## New Distribution (Multi-Step) (auth required)
|
## New Distribution (Multi-Step) (auth required)
|
||||||
|
|
||||||
| Dark Mode | Light Mode |
|
| Dark Mode | Light Mode |
|
||||||
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|-----------|------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Stock & Batch Management (auth required)
|
## Stock & Batch Management (auth required)
|
||||||
|
|
||||||
| Dark Mode | Light Mode |
|
| Dark Mode | Light Mode |
|
||||||
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
|-----------|------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Add New Batch (auth required)
|
## Add New Batch (auth required)
|
||||||
|
|
||||||
| Dark Mode | Light Mode |
|
| Dark Mode | Light Mode |
|
||||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
|-----------|------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Compliance Reports (auth required)
|
## Compliance Reports (auth required)
|
||||||
|
|
||||||
| Dark Mode | Light Mode |
|
| Dark Mode | Light Mode |
|
||||||
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|-----------|------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Member Quota Overview
|
## Member Quota Overview
|
||||||
|
|
||||||
| Dark Mode | Light Mode |
|
| Dark Mode | Light Mode |
|
||||||
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|-----------|------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## My Distribution History
|
## My Distribution History
|
||||||
|
|
||||||
| Dark Mode | Light Mode |
|
| Dark Mode | Light Mode |
|
||||||
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|-----------|------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Profile & Settings
|
## Profile & Settings
|
||||||
|
|
||||||
| Dark Mode | Light Mode |
|
| Dark Mode | Light Mode |
|
||||||
| ------------------------------------------------------------------ | -------------------------------------------------------------------- |
|
|-----------|------------|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"status": "passed",
|
"status": "passed",
|
||||||
"failedTests": []
|
"failedTests": []
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -743,5 +743,34 @@
|
|||||||
"s10Title": "§ 10 Schlussbestimmungen",
|
"s10Title": "§ 10 Schlussbestimmungen",
|
||||||
"s10Content": "Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist, soweit gesetzlich zulässig, der Sitz des Anbieters. Sollten einzelne Bestimmungen dieser AGB unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt. Änderungen der AGB werden dem Nutzer rechtzeitig mitgeteilt."
|
"s10Content": "Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist, soweit gesetzlich zulässig, der Sitz des Anbieters. Sollten einzelne Bestimmungen dieser AGB unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt. Änderungen der AGB werden dem Nutzer rechtzeitig mitgeteilt."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"infoBoard": {
|
||||||
|
"title": "Schwarzes Brett",
|
||||||
|
"description": "Neuigkeiten und Ankündigungen für alle Mitglieder",
|
||||||
|
"createPost": "Beitrag erstellen",
|
||||||
|
"postTitle": "Titel",
|
||||||
|
"postTitlePlaceholder": "Titel des Beitrags...",
|
||||||
|
"postContent": "Inhalt",
|
||||||
|
"postContentPlaceholder": "Schreibe deinen Beitrag...",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"categories": {
|
||||||
|
"GENERAL": "Allgemein",
|
||||||
|
"EVENT": "Veranstaltung",
|
||||||
|
"RULE": "Regelung",
|
||||||
|
"MAINTENANCE": "Wartung"
|
||||||
|
},
|
||||||
|
"pinPost": "Anheften",
|
||||||
|
"archive": "Archivieren",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"publish": "Veröffentlichen",
|
||||||
|
"creating": "Wird erstellt...",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"allCategories": "Alle Kategorien",
|
||||||
|
"showArchived": "Archiviert",
|
||||||
|
"archived": "Archiviert",
|
||||||
|
"loading": "Beiträge werden geladen...",
|
||||||
|
"noPosts": "Noch keine Beiträge vorhanden. Erstelle den ersten Beitrag!",
|
||||||
|
"confirmDelete": "Möchtest du diesen Beitrag wirklich löschen?",
|
||||||
|
"unreadCount": "{count} ungelesen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -688,5 +688,34 @@
|
|||||||
"s10Title": "§ 10 Final Provisions",
|
"s10Title": "§ 10 Final Provisions",
|
||||||
"s10Content": "The law of the Federal Republic of Germany applies. The place of jurisdiction is, to the extent legally permissible, the registered office of the provider. Should individual provisions of these terms be invalid, the validity of the remaining provisions remains unaffected. Changes to these terms will be communicated to the user in good time."
|
"s10Content": "The law of the Federal Republic of Germany applies. The place of jurisdiction is, to the extent legally permissible, the registered office of the provider. Should individual provisions of these terms be invalid, the validity of the remaining provisions remains unaffected. Changes to these terms will be communicated to the user in good time."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"infoBoard": {
|
||||||
|
"title": "Info Board",
|
||||||
|
"description": "News and announcements for all members",
|
||||||
|
"createPost": "Create Post",
|
||||||
|
"postTitle": "Title",
|
||||||
|
"postTitlePlaceholder": "Post title...",
|
||||||
|
"postContent": "Content",
|
||||||
|
"postContentPlaceholder": "Write your post...",
|
||||||
|
"category": "Category",
|
||||||
|
"categories": {
|
||||||
|
"GENERAL": "General",
|
||||||
|
"EVENT": "Event",
|
||||||
|
"RULE": "Rule",
|
||||||
|
"MAINTENANCE": "Maintenance"
|
||||||
|
},
|
||||||
|
"pinPost": "Pin",
|
||||||
|
"archive": "Archive",
|
||||||
|
"delete": "Delete",
|
||||||
|
"publish": "Publish",
|
||||||
|
"creating": "Creating...",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"allCategories": "All Categories",
|
||||||
|
"showArchived": "Archived",
|
||||||
|
"archived": "Archived",
|
||||||
|
"loading": "Loading posts...",
|
||||||
|
"noPosts": "No posts yet. Create the first one!",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this post?",
|
||||||
|
"unreadCount": "{count} unread"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
useArchivePostMutation,
|
||||||
|
useCreatePostMutation,
|
||||||
|
useDeletePostMutation,
|
||||||
|
useInfoBoardPostsQuery,
|
||||||
|
useTogglePinMutation,
|
||||||
|
} from "@/services/info-board"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
BookOpen,
|
||||||
|
Calendar,
|
||||||
|
Filter,
|
||||||
|
Megaphone,
|
||||||
|
Pin,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import type { InfoBoardCategory, InfoBoardPost } from "@/services/info-board"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select } from "@/components/ui/select"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
const categoryIcons: Record<InfoBoardCategory, React.ReactNode> = {
|
||||||
|
EVENT: <Calendar className="h-4 w-4" />,
|
||||||
|
RULE: <BookOpen className="h-4 w-4" />,
|
||||||
|
GENERAL: <Megaphone className="h-4 w-4" />,
|
||||||
|
MAINTENANCE: <Wrench className="h-4 w-4" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryColors: Record<InfoBoardCategory, string> = {
|
||||||
|
EVENT: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
|
||||||
|
RULE: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300",
|
||||||
|
GENERAL: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
|
||||||
|
MAINTENANCE:
|
||||||
|
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock club ID for development
|
||||||
|
const MOCK_CLUB_ID = "00000000-0000-0000-0000-000000000001"
|
||||||
|
|
||||||
|
export default function InfoBoardPage() {
|
||||||
|
const t = useTranslations("infoBoard")
|
||||||
|
const [filterCategory, setFilterCategory] = useState<
|
||||||
|
InfoBoardCategory | "ALL"
|
||||||
|
>("ALL")
|
||||||
|
const [includeArchived, setIncludeArchived] = useState(false)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [content, setContent] = useState("")
|
||||||
|
const [category, setCategory] = useState<InfoBoardCategory>("GENERAL")
|
||||||
|
const [pinned, setPinned] = useState(false)
|
||||||
|
|
||||||
|
const { data, isLoading } = useInfoBoardPostsQuery(MOCK_CLUB_ID, {
|
||||||
|
category: filterCategory === "ALL" ? undefined : filterCategory,
|
||||||
|
includeArchived,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useCreatePostMutation()
|
||||||
|
const deleteMutation = useDeletePostMutation()
|
||||||
|
const archiveMutation = useArchivePostMutation()
|
||||||
|
const togglePinMutation = useTogglePinMutation()
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!title.trim() || !content.trim()) return
|
||||||
|
createMutation.mutate(
|
||||||
|
{ clubId: MOCK_CLUB_ID, title, content, category, pinned },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setTitle("")
|
||||||
|
setContent("")
|
||||||
|
setCategory("GENERAL")
|
||||||
|
setPinned(false)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts: InfoBoardPost[] = data?.posts ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("description")}</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t("createPost")}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("createPost")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 pt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">{t("postTitle")}</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={t("postTitlePlaceholder")}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="content">{t("postContent")}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder={t("postContentPlaceholder")}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("category")}</Label>
|
||||||
|
<Select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCategory(e.target.value as InfoBoardCategory)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="GENERAL">{t("categories.GENERAL")}</option>
|
||||||
|
<option value="EVENT">{t("categories.EVENT")}</option>
|
||||||
|
<option value="RULE">{t("categories.RULE")}</option>
|
||||||
|
<option value="MAINTENANCE">
|
||||||
|
{t("categories.MAINTENANCE")}
|
||||||
|
</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end space-x-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={pinned ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPinned(!pinned)}
|
||||||
|
>
|
||||||
|
<Pin className="mr-1 h-4 w-4" />
|
||||||
|
{t("pinPost")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? t("creating") : t("publish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Select
|
||||||
|
value={filterCategory}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilterCategory(e.target.value as InfoBoardCategory | "ALL")
|
||||||
|
}
|
||||||
|
className="w-[180px]"
|
||||||
|
>
|
||||||
|
<option value="ALL">{t("allCategories")}</option>
|
||||||
|
<option value="GENERAL">{t("categories.GENERAL")}</option>
|
||||||
|
<option value="EVENT">{t("categories.EVENT")}</option>
|
||||||
|
<option value="RULE">{t("categories.RULE")}</option>
|
||||||
|
<option value="MAINTENANCE">{t("categories.MAINTENANCE")}</option>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant={includeArchived ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIncludeArchived(!includeArchived)}
|
||||||
|
>
|
||||||
|
<Archive className="mr-1 h-4 w-4" />
|
||||||
|
{t("showArchived")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Posts List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground py-12 text-center">
|
||||||
|
{t("loading")}
|
||||||
|
</div>
|
||||||
|
) : posts.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="text-muted-foreground py-12 text-center">
|
||||||
|
{t("noPosts")}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<Card key={post.id} className={post.archived ? "opacity-60" : ""}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{post.pinned && (
|
||||||
|
<Pin className="h-4 w-4 fill-amber-500 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<CardTitle className="text-lg">{post.title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => togglePinMutation.mutate(post.id)}
|
||||||
|
title={t("pinPost")}
|
||||||
|
>
|
||||||
|
<Pin
|
||||||
|
className={`h-4 w-4 ${post.pinned ? "fill-current" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => archiveMutation.mutate(post.id)}
|
||||||
|
title={t("archive")}
|
||||||
|
>
|
||||||
|
<Archive className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(t("confirmDelete"))) {
|
||||||
|
deleteMutation.mutate(post.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="text-destructive h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
className={categoryColors[post.category]}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{categoryIcons[post.category]}
|
||||||
|
<span className="ml-1">
|
||||||
|
{t(`categories.${post.category}`)}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
{post.archived && (
|
||||||
|
<Badge variant="outline">{t("archived")}</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{new Date(post.createdAt).toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className="prose prose-sm dark:prose-invert max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,11 @@ export const navigationsData: NavigationType[] = [
|
|||||||
href: "/reports",
|
href: "/reports",
|
||||||
iconName: "FileText",
|
iconName: "FileText",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Schwarzes Brett",
|
||||||
|
href: "/info-board",
|
||||||
|
iconName: "Megaphone",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Personal",
|
title: "Personal",
|
||||||
href: "/settings/staff",
|
href: "/settings/staff",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export async function subscribeToPush(): Promise<PushSubscription | null> {
|
|||||||
// Subscribe to push
|
// Subscribe to push
|
||||||
const subscription = await registration.pushManager.subscribe({
|
const subscription = await registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send subscription to backend
|
// Send subscription to backend
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
|
||||||
|
import { apiClient } from "@/lib/api-client"
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export type InfoBoardCategory = "EVENT" | "RULE" | "GENERAL" | "MAINTENANCE"
|
||||||
|
|
||||||
|
export interface InfoBoardPost {
|
||||||
|
id: string
|
||||||
|
clubId: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
category: InfoBoardCategory
|
||||||
|
pinned: boolean
|
||||||
|
archived: boolean
|
||||||
|
authorId: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfoBoardPostsResponse {
|
||||||
|
posts: InfoBoardPost[]
|
||||||
|
totalElements: number
|
||||||
|
totalPages: number
|
||||||
|
page: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePostRequest {
|
||||||
|
clubId: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
category: InfoBoardCategory
|
||||||
|
pinned?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePostRequest {
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
category?: InfoBoardCategory
|
||||||
|
pinned?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Query Hooks ---
|
||||||
|
|
||||||
|
export function useInfoBoardPostsQuery(
|
||||||
|
clubId: string | undefined,
|
||||||
|
options?: {
|
||||||
|
category?: InfoBoardCategory
|
||||||
|
includeArchived?: boolean
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["info-board", clubId, options],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (clubId) params.set("clubId", clubId)
|
||||||
|
if (options?.category) params.set("category", options.category)
|
||||||
|
if (options?.includeArchived) params.set("includeArchived", "true")
|
||||||
|
params.set("page", String(options?.page ?? 0))
|
||||||
|
params.set("size", String(options?.size ?? 20))
|
||||||
|
return apiClient<InfoBoardPostsResponse>(`/info-board?${params}`)
|
||||||
|
},
|
||||||
|
enabled: !!clubId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInfoBoardPostQuery(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["info-board", id],
|
||||||
|
queryFn: () => apiClient<InfoBoardPost>(`/info-board/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePortalInfoBoardQuery(
|
||||||
|
clubId: string | undefined,
|
||||||
|
options?: { category?: InfoBoardCategory; page?: number }
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["portal-info-board", clubId, options],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (clubId) params.set("clubId", clubId)
|
||||||
|
if (options?.category) params.set("category", options.category)
|
||||||
|
params.set("page", String(options?.page ?? 0))
|
||||||
|
return apiClient<InfoBoardPostsResponse>(`/portal/info-board?${params}`)
|
||||||
|
},
|
||||||
|
enabled: !!clubId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnreadCountQuery(
|
||||||
|
clubId: string | undefined,
|
||||||
|
memberId: string | undefined
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["info-board-unread", clubId, memberId],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (clubId) params.set("clubId", clubId)
|
||||||
|
if (memberId) params.set("memberId", memberId)
|
||||||
|
return apiClient<{ unreadCount: number }>(
|
||||||
|
`/portal/info-board/unread-count?${params}`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled: !!clubId && !!memberId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mutation Hooks ---
|
||||||
|
|
||||||
|
export function useCreatePostMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreatePostRequest) =>
|
||||||
|
apiClient<InfoBoardPost>("/info-board", { method: "POST", body: data }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["info-board"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePostMutation(id: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: UpdatePostRequest) =>
|
||||||
|
apiClient<InfoBoardPost>(`/info-board/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: data,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["info-board"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeletePostMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<{ deleted: boolean }>(`/info-board/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["info-board"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArchivePostMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<InfoBoardPost>(`/info-board/${id}/archive`, { method: "POST" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["info-board"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTogglePinMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<InfoBoardPost>(`/info-board/${id}/pin`, { method: "POST" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["info-board"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkAsReadMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ postId, memberId }: { postId: string; memberId: string }) =>
|
||||||
|
apiClient<{ read: boolean }>(
|
||||||
|
`/portal/info-board/${postId}/read?memberId=${memberId}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["info-board-unread"] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["portal-info-board"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -72,7 +72,9 @@ export async function registerDevice(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDevices(): Promise<{ devices: DeviceTokenResponse[] }> {
|
export async function getDevices(): Promise<{
|
||||||
|
devices: DeviceTokenResponse[]
|
||||||
|
}> {
|
||||||
return apiClient<{ devices: DeviceTokenResponse[] }>("/notifications/devices")
|
return apiClient<{ devices: DeviceTokenResponse[] }>("/notifications/devices")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.InfoBoardPost;
|
||||||
|
import de.cannamanage.domain.entity.PostReadStatus;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.domain.enums.AuditEventType;
|
||||||
|
import de.cannamanage.domain.enums.InfoBoardCategory;
|
||||||
|
import de.cannamanage.domain.enums.NotificationType;
|
||||||
|
import de.cannamanage.service.repository.InfoBoardPostRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.PostReadStatusRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for Info Board (Schwarzes Brett) operations.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class InfoBoardService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InfoBoardService.class);
|
||||||
|
|
||||||
|
private final InfoBoardPostRepository postRepository;
|
||||||
|
private final PostReadStatusRepository readStatusRepository;
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
public InfoBoardService(InfoBoardPostRepository postRepository,
|
||||||
|
PostReadStatusRepository readStatusRepository,
|
||||||
|
MemberRepository memberRepository,
|
||||||
|
NotificationService notificationService,
|
||||||
|
AuditService auditService) {
|
||||||
|
this.postRepository = postRepository;
|
||||||
|
this.readStatusRepository = readStatusRepository;
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
this.notificationService = notificationService;
|
||||||
|
this.auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new info board post and notify all club members.
|
||||||
|
*/
|
||||||
|
public InfoBoardPost createPost(UUID clubId, String title, String content,
|
||||||
|
InfoBoardCategory category, boolean isPinned, UUID authorId) {
|
||||||
|
InfoBoardPost post = new InfoBoardPost(clubId, title, content, category, authorId);
|
||||||
|
post.setPinned(isPinned);
|
||||||
|
|
||||||
|
InfoBoardPost saved = postRepository.save(post);
|
||||||
|
log.info("Info board post created: {} in club {}", saved.getId(), clubId);
|
||||||
|
|
||||||
|
// Dispatch notification to all club members
|
||||||
|
try {
|
||||||
|
var members = memberRepository.findByClubId(clubId);
|
||||||
|
members.forEach(member -> {
|
||||||
|
if (member.getUserId() != null) {
|
||||||
|
notificationService.createNotification(
|
||||||
|
member.getUserId(),
|
||||||
|
NotificationType.INFO_BOARD_POST,
|
||||||
|
title,
|
||||||
|
"Neuer Beitrag im Schwarzen Brett: " + title,
|
||||||
|
"/portal/info-board"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to send info board notifications: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
auditService.log(
|
||||||
|
AuditEventType.INFO_BOARD_POST_CREATED,
|
||||||
|
"InfoBoardPost",
|
||||||
|
saved.getId(),
|
||||||
|
authorId,
|
||||||
|
null, null,
|
||||||
|
"Info board post created: " + title,
|
||||||
|
"{\"category\":\"" + category.name() + "\",\"pinned\":" + isPinned + "}",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing post.
|
||||||
|
*/
|
||||||
|
public InfoBoardPost updatePost(UUID postId, String title, String content,
|
||||||
|
InfoBoardCategory category, Boolean isPinned) {
|
||||||
|
InfoBoardPost post = postRepository.findById(postId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + postId));
|
||||||
|
|
||||||
|
if (title != null) post.setTitle(title);
|
||||||
|
if (content != null) post.setContent(content);
|
||||||
|
if (category != null) post.setCategory(category);
|
||||||
|
if (isPinned != null) post.setPinned(isPinned);
|
||||||
|
|
||||||
|
InfoBoardPost saved = postRepository.save(post);
|
||||||
|
log.debug("Info board post updated: {}", postId);
|
||||||
|
|
||||||
|
auditService.log(
|
||||||
|
AuditEventType.INFO_BOARD_POST_UPDATED,
|
||||||
|
"InfoBoardPost",
|
||||||
|
postId,
|
||||||
|
null, null, null,
|
||||||
|
"Info board post updated: " + post.getTitle(),
|
||||||
|
null, null
|
||||||
|
);
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive a post (hides from portal members).
|
||||||
|
*/
|
||||||
|
public InfoBoardPost archivePost(UUID postId) {
|
||||||
|
InfoBoardPost post = postRepository.findById(postId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + postId));
|
||||||
|
post.setArchived(true);
|
||||||
|
return postRepository.save(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unarchive a post.
|
||||||
|
*/
|
||||||
|
public InfoBoardPost unarchivePost(UUID postId) {
|
||||||
|
InfoBoardPost post = postRepository.findById(postId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + postId));
|
||||||
|
post.setArchived(false);
|
||||||
|
return postRepository.save(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a post permanently.
|
||||||
|
*/
|
||||||
|
public void deletePost(UUID postId) {
|
||||||
|
InfoBoardPost post = postRepository.findById(postId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + postId));
|
||||||
|
|
||||||
|
postRepository.delete(post);
|
||||||
|
log.info("Info board post deleted: {}", postId);
|
||||||
|
|
||||||
|
auditService.log(
|
||||||
|
AuditEventType.INFO_BOARD_POST_DELETED,
|
||||||
|
"InfoBoardPost",
|
||||||
|
postId,
|
||||||
|
null, null, null,
|
||||||
|
"Info board post deleted: " + post.getTitle(),
|
||||||
|
null, null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get posts for a club (admin view — optionally includes archived).
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<InfoBoardPost> getPosts(UUID clubId, InfoBoardCategory category,
|
||||||
|
boolean includeArchived, int page, int size) {
|
||||||
|
var pageable = PageRequest.of(page, size);
|
||||||
|
|
||||||
|
if (includeArchived) {
|
||||||
|
if (category != null) {
|
||||||
|
return postRepository.findByClubIdAndCategoryOrderByPinnedDescCreatedAtDesc(clubId, category, pageable);
|
||||||
|
}
|
||||||
|
return postRepository.findByClubIdOrderByPinnedDescCreatedAtDesc(clubId, pageable);
|
||||||
|
} else {
|
||||||
|
if (category != null) {
|
||||||
|
return postRepository.findByClubIdAndCategoryAndArchivedFalseOrderByPinnedDescCreatedAtDesc(clubId, category, pageable);
|
||||||
|
}
|
||||||
|
return postRepository.findByClubIdAndArchivedFalseOrderByPinnedDescCreatedAtDesc(clubId, pageable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single post by ID.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public InfoBoardPost getPost(UUID postId) {
|
||||||
|
return postRepository.findById(postId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + postId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a post as read by a member.
|
||||||
|
*/
|
||||||
|
public void markAsRead(UUID postId, UUID memberId) {
|
||||||
|
if (!readStatusRepository.existsByPostIdAndMemberId(postId, memberId)) {
|
||||||
|
readStatusRepository.save(new PostReadStatus(postId, memberId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread post count for a member in their club.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long getUnreadCount(UUID clubId, UUID memberId) {
|
||||||
|
return postRepository.countUnreadByClubIdAndMemberId(clubId, memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle pin status.
|
||||||
|
*/
|
||||||
|
public InfoBoardPost togglePin(UUID postId) {
|
||||||
|
InfoBoardPost post = postRepository.findById(postId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + postId));
|
||||||
|
post.setPinned(!post.isPinned());
|
||||||
|
return postRepository.save(post);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.InfoBoardPost;
|
||||||
|
import de.cannamanage.domain.enums.InfoBoardCategory;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface InfoBoardPostRepository extends JpaRepository<InfoBoardPost, UUID> {
|
||||||
|
|
||||||
|
Page<InfoBoardPost> findByClubIdAndArchivedFalseOrderByPinnedDescCreatedAtDesc(
|
||||||
|
UUID clubId, Pageable pageable);
|
||||||
|
|
||||||
|
Page<InfoBoardPost> findByClubIdAndCategoryAndArchivedFalseOrderByPinnedDescCreatedAtDesc(
|
||||||
|
UUID clubId, InfoBoardCategory category, Pageable pageable);
|
||||||
|
|
||||||
|
Page<InfoBoardPost> findByClubIdOrderByPinnedDescCreatedAtDesc(
|
||||||
|
UUID clubId, Pageable pageable);
|
||||||
|
|
||||||
|
Page<InfoBoardPost> findByClubIdAndCategoryOrderByPinnedDescCreatedAtDesc(
|
||||||
|
UUID clubId, InfoBoardCategory category, Pageable pageable);
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(p) FROM InfoBoardPost p WHERE p.clubId = :clubId AND p.archived = false " +
|
||||||
|
"AND p.id NOT IN (SELECT r.postId FROM PostReadStatus r WHERE r.memberId = :memberId)")
|
||||||
|
long countUnreadByClubIdAndMemberId(@Param("clubId") UUID clubId, @Param("memberId") UUID memberId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.PostAttachment;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface PostAttachmentRepository extends JpaRepository<PostAttachment, UUID> {
|
||||||
|
|
||||||
|
List<PostAttachment> findByPostId(UUID postId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.PostReadStatus;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface PostReadStatusRepository extends JpaRepository<PostReadStatus, PostReadStatus.PostReadStatusId> {
|
||||||
|
|
||||||
|
boolean existsByPostIdAndMemberId(UUID postId, UUID memberId);
|
||||||
|
}
|
||||||