Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4947403
chore(deps): Add bwip-js for barcode generation
OffCrazyFreak Jul 30, 2026
2e96207
feat(digital-cards): Add community store name suggestions
OffCrazyFreak Jul 30, 2026
89c9d84
feat(digital-cards): Rebuild the digital card backend
OffCrazyFreak Jul 30, 2026
ee93e3a
feat(digital-cards): Add the card API layer and offline wiring
OffCrazyFreak Jul 30, 2026
0a0c992
feat(digital-cards): Add code, colour and icon rendering primitives
OffCrazyFreak Jul 30, 2026
aa29040
feat(digital-cards): Add the card form and checkout modals
OffCrazyFreak Jul 30, 2026
b2b522f
feat(digital-cards): Add the wallet page
OffCrazyFreak Jul 30, 2026
5eaac33
feat(digital-cards): Activate the nav, PWA shortcut and landing entries
OffCrazyFreak Jul 30, 2026
432db56
Merge remote-tracking branch 'origin/dev' into feat/digital-cards-rework
OffCrazyFreak Jul 30, 2026
511b8a7
fix(digital-cards): Keep the card code out of storage but alive acros…
OffCrazyFreak Jul 30, 2026
83bbc25
Merge remote-tracking branch 'origin/dev' into feat/digital-cards-rework
OffCrazyFreak Aug 5, 2026
1b24bb8
fix(digital-cards): Adopt the UTC clock and key the card modals per card
OffCrazyFreak Aug 5, 2026
753714a
fix(digital-cards): Apply the review findings on the card surfaces
OffCrazyFreak Aug 5, 2026
20f8118
Merge remote-tracking branch 'origin/dev' into feat/digital-cards-rework
OffCrazyFreak Aug 5, 2026
2ae066f
Merge remote-tracking branch 'origin/dev' into feat/digital-cards-rework
OffCrazyFreak Aug 7, 2026
6130c55
fix(digital-cards): Make card text legible and adopt the newer conven…
OffCrazyFreak Aug 7, 2026
5411c36
docs(digital-cards): Document the wallet and correct what the rework …
OffCrazyFreak Aug 7, 2026
ee32321
Merge remote-tracking branch 'origin/dev' into feat/digital-cards-rework
OffCrazyFreak Aug 7, 2026
7f68588
fix(digital-cards): Follow dev's offline error and draft conventions
OffCrazyFreak Aug 7, 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
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,9 @@
@Repository
public interface DigitalCardRepository extends JpaRepository<DigitalCard, UUID> {

@Query("SELECT dc FROM DigitalCard dc WHERE dc.user = :user AND dc.deletedAt IS NULL")
@Query("SELECT dc FROM DigitalCard dc WHERE dc.user = :user AND dc.deletedAt IS NULL ORDER BY dc.updatedAt DESC")
List<DigitalCard> findActiveByUser(User user);

@Query("SELECT dc FROM DigitalCard dc WHERE dc.id = :id AND dc.deletedAt IS NULL")
Optional<DigitalCard> findActiveById(UUID id);

@Query("SELECT dc FROM DigitalCard dc WHERE dc.id = :id AND dc.user = :user AND dc.deletedAt IS NULL")
Optional<DigitalCard> findActiveByIdAndUser(UUID id, User user);
}
61 changes: 45 additions & 16 deletions backend/src/main/java/disscount/digitalCard/domain/DigitalCard.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import disscount.util.Timestamps;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import lombok.*;

import java.time.LocalDateTime;
Expand All @@ -27,36 +26,66 @@ public class DigitalCard {
@JoinColumn(name = "user_id", nullable = false)
private User user;

@NotBlank(message = "Title is required")
@Column(nullable = false)
private String title;
@Column(name = "card_name", nullable = false)
private String cardName;

@NotBlank(message = "Type is required")
@Column(name = "type")
private String type;
// Plain String, never @Enumerated: ddl-auto=update leaves a stale CHECK constraint
// behind if the vocabulary ever changes. Values: loyalty | gift | membership | other.
@Column(name = "card_type", nullable = false, length = 16)
private String cardType;

@NotBlank(message = "Value is required")
@Column(nullable = false)
private String value;
@Column(name = "store_name", nullable = false)
private String storeName;

@NotBlank(message = "Code type is required")
@Column(name = "code_type", nullable = false)
// cijene-api chain code when the user picked an official chain; null for free text.
@Column(name = "chain_code", length = 40)
private String chainCode;

@Column(name = "code_value", nullable = false, length = 4096)
private String codeValue;

// Barcode Detection API format name (ean_13, qr_code, ...) or "unknown".
@Column(name = "code_type", nullable = false, length = 32)
private String codeType;

@Column
private String color;
@Column(name = "card_color", nullable = false, length = 7)
private String cardColor;

@Column(name = "icon_image", columnDefinition = "TEXT")
private String iconImage;

@Column(name = "front_image", columnDefinition = "TEXT")
private String frontImage;

@Column(name = "back_image", columnDefinition = "TEXT")
private String backImage;

@Column(columnDefinition = "TEXT")
@Column(name = "note", length = 500)
private String note;

// Null = not pinned, matching User's toggle timestamps. Pinning goes through
// @PreUpdate, so it counts as a change and bumps updatedAt.
@Column(name = "pinned_at")
private LocalDateTime pinnedAt;

@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;

@Column(name = "deleted_at")
private LocalDateTime deletedAt;

@PrePersist
protected void onCreate() {
createdAt = Timestamps.nowUtc();
LocalDateTime now = Timestamps.nowUtc();
createdAt = now;
updatedAt = now;
}

@PreUpdate
protected void onUpdate() {
updatedAt = Timestamps.nowUtc();
}
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
package disscount.digitalCard.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.UUID;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DigitalCardDto {

private UUID id;
private String title;
private String value;
private String type;
private UUID userId;
private String cardName;
private String cardType;
private String storeName;
private String chainCode;
private String codeValue;
private String codeType;
private String color;
private String cardColor;
private String iconImage;
private String frontImage;
private String backImage;
private String note;
private LocalDateTime pinnedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,53 @@
package disscount.digitalCard.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
public class DigitalCardRequest {

@NotBlank(message = "Title is required")
private String title;
@NotBlank(message = "Naziv kartice je obavezan")
@Size(min = 2, max = 60, message = "Naziv kartice mora imati između 2 i 60 znakova")
private String cardName;

@NotBlank(message = "Value is required")
private String value;
@NotBlank(message = "Tip kartice je obavezan")
@Pattern(regexp = "loyalty|gift|membership|other", message = "Neispravan tip kartice")
private String cardType;

@NotBlank(message = "Type is required")
private String type;
@NotBlank(message = "Naziv trgovine je obavezan")
@Size(min = 2, max = 60, message = "Naziv trgovine mora imati između 2 i 60 znakova")
private String storeName;

@NotBlank(message = "Code type is required")
@Size(max = 40, message = "Neispravna oznaka trgovine")
private String chainCode;

@NotBlank(message = "Kod kartice je obavezan")
@Size(max = 4096, message = "Kod kartice je predug")
private String codeValue;

// Barcode Detection API vocabulary, deliberately not pattern-constrained beyond
// length: a browser adding a format should not need a backend release.
@NotBlank(message = "Tip koda je obavezan")
@Size(max = 32, message = "Neispravan tip koda")
private String codeType;

private String color;
@NotBlank(message = "Boja kartice je obavezna")
@Pattern(regexp = "#[0-9a-fA-F]{6}", message = "Neispravna boja kartice")
private String cardColor;

// Base64 data URIs. The client downscales the icon to 256px and the card faces to
// 1024px WebP, so these are abuse backstops rather than the real limits.
@Size(max = 400_000, message = "Ikona je prevelika")
private String iconImage;

@Size(max = 1_200_000, message = "Slika prednje strane je prevelika")
private String frontImage;

@Size(max = 1_200_000, message = "Slika stražnje strane je prevelika")
private String backImage;

@Size(max = 500, message = "Bilješka može imati najviše 500 znakova")
private String note;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@
import disscount.util.SecurityUtils;

import java.util.List;
import java.util.Map;
import java.util.UUID;

@RestController
@RequestMapping("/api/digital-cards")
@RequiredArgsConstructor
@Tag(name = "Digital Cards", description = "Digital card management endpoints")
@Tag(name = "Digital Cards", description = "Loyalty card wallet endpoints")
public class DigitalCardController {

private final DigitalCardService digitalCardService;
Expand All @@ -28,42 +27,47 @@ public class DigitalCardController {
@PostMapping
public ResponseEntity<DigitalCardDto> createCard(@Valid @RequestBody DigitalCardRequest request) {
UUID userId = SecurityUtils.getCurrentUserId();
DigitalCardDto card = digitalCardService.createCard(userId, request);
return ResponseEntity.ok(card);
DigitalCardDto created = digitalCardService.createCard(userId, request);
return ResponseEntity.ok(created);
}

@Operation(summary = "Get all digital cards for current user")
@Operation(summary = "Get current user's digital cards")
@GetMapping("/me")
public ResponseEntity<List<DigitalCardDto>> getUserCards() {
public ResponseEntity<List<DigitalCardDto>> getCurrentUserCards() {
UUID userId = SecurityUtils.getCurrentUserId();
List<DigitalCardDto> cards = digitalCardService.getUserCards(userId);
return ResponseEntity.ok(cards);
}

@Operation(summary = "Get digital card by ID")
@GetMapping("/{id}")
public ResponseEntity<DigitalCardDto> getCardById(@PathVariable UUID id) {
UUID userId = SecurityUtils.getCurrentUserId();
return digitalCardService.getCardById(id, userId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}

@Operation(summary = "Update digital card")
@PutMapping("/{id}")
public ResponseEntity<DigitalCardDto> updateCard(
@PathVariable UUID id,
@RequestBody DigitalCardRequest request) {
@Valid @RequestBody DigitalCardRequest request) {
UUID userId = SecurityUtils.getCurrentUserId();
DigitalCardDto updatedCard = digitalCardService.updateCard(id, userId, request);
return ResponseEntity.ok(updatedCard);
DigitalCardDto updated = digitalCardService.updateCard(id, userId, request);
return ResponseEntity.ok(updated);
}

@Operation(summary = "Delete digital card (soft delete)")
@Operation(summary = "Delete digital card")
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, String>> deleteCard(@PathVariable UUID id) {
public ResponseEntity<Void> deleteCard(@PathVariable UUID id) {
UUID userId = SecurityUtils.getCurrentUserId();
digitalCardService.deleteCard(id, userId);
return ResponseEntity.ok(Map.of("message", "Card deleted successfully"));
return ResponseEntity.noContent().build();
}

@Operation(summary = "Pin digital card")
@PatchMapping("/{id}/pin")
public ResponseEntity<DigitalCardDto> pinCard(@PathVariable UUID id) {
UUID userId = SecurityUtils.getCurrentUserId();
return ResponseEntity.ok(digitalCardService.setPinned(id, userId, true));
}

@Operation(summary = "Unpin digital card")
@PatchMapping("/{id}/unpin")
public ResponseEntity<DigitalCardDto> unpinCard(@PathVariable UUID id) {
UUID userId = SecurityUtils.getCurrentUserId();
return ResponseEntity.ok(digitalCardService.setPinned(id, userId, false));
}
}
Loading