diff --git a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java index cdc2962..1150ad2 100644 --- a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java +++ b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java @@ -55,6 +55,7 @@ public class SecurityConfig { .permitAll() .requestMatchers("/api/webhooks/**").permitAll() .requestMatchers("/api/payment/swish-info").permitAll() + .requestMatchers("/api/guest-orders/**").permitAll() .requestMatchers("/api/vehicles/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) diff --git a/backend/src/main/java/se/bilhalsning/controller/GuestOrderController.java b/backend/src/main/java/se/bilhalsning/controller/GuestOrderController.java new file mode 100644 index 0000000..de8c3ed --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/controller/GuestOrderController.java @@ -0,0 +1,75 @@ +package se.bilhalsning.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import se.bilhalsning.dto.CreateGuestOrderRequest; +import se.bilhalsning.dto.GuestOrderResponse; +import se.bilhalsning.entity.Order; +import se.bilhalsning.service.OrderService; + +import java.util.UUID; + +/** + * Public (no-JWT) endpoints for placing and paying for orders without an + * account — guest checkout. + * + * Auth: white-listed in {@code SecurityConfig} so no JWT filter runs on + * these paths. The guest token (UUID v4, generated at order create time + * in {@link Order#onCreate()}) is the only credential the client holds + * and must pass as a path variable. Token brute-force resistance: 122 + * bits of entropy. + * + * Routes parallel {@link OrderController} deliberately so the JWT path + * stays clean and unmodified. + */ +@RestController +@RequestMapping("/api/guest-orders") +@RequiredArgsConstructor +public class GuestOrderController { + + private final OrderService orderService; + + @PostMapping + public ResponseEntity create( + @Valid @RequestBody CreateGuestOrderRequest request) { + Order order = orderService.createGuestOrder( + request.plate(), + request.letterText(), + request.email() + ); + return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order)); + } + + @GetMapping("/{token}") + public ResponseEntity get(@PathVariable UUID token) { + Order order = orderService.getOrderByGuestToken(token); + return ResponseEntity.ok(toResponse(order)); + } + + @PostMapping("/{token}/pay") + public ResponseEntity pay(@PathVariable UUID token) { + Order order = orderService.confirmGuestPayment(token); + return ResponseEntity.ok(toResponse(order)); + } + + private GuestOrderResponse toResponse(Order order) { + return new GuestOrderResponse( + order.getId(), + order.getPlate(), + order.getLetterText(), + order.getStatus().getValue(), + order.getTrackingId(), + order.getAmountPaid(), + order.getCreatedAt(), + order.getGuestToken() + ); + } +} diff --git a/backend/src/main/java/se/bilhalsning/dto/CreateGuestOrderRequest.java b/backend/src/main/java/se/bilhalsning/dto/CreateGuestOrderRequest.java new file mode 100644 index 0000000..651c522 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/CreateGuestOrderRequest.java @@ -0,0 +1,27 @@ +package se.bilhalsning.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Create an order without an account (guest checkout). + * + * The {@code plate} validation mirrors {@link CreateOrderRequest} so the + * guest path accepts the same Swedish 3-letter + 3-char plate format. + */ +public record CreateGuestOrderRequest( + @NotBlank(message = "Registreringsnummer krävs") + @Pattern(regexp = "^[A-Za-z]{3}\\d{2}[A-Za-z0-9]$", message = "Ogiltigt registreringsnummer") + String plate, + + @NotBlank(message = "Brevtext krävs") + @Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken") + String letterText, + + @NotBlank(message = "E-post krävs") + @Email(message = "Ogiltig e-postadress") + @Size(max = 255, message = "E-postadressen är för lång") + String email +) {} diff --git a/backend/src/main/java/se/bilhalsning/dto/GuestOrderResponse.java b/backend/src/main/java/se/bilhalsning/dto/GuestOrderResponse.java new file mode 100644 index 0000000..6e1a120 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/GuestOrderResponse.java @@ -0,0 +1,24 @@ +package se.bilhalsning.dto; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +/** + * Response shape for guest-order endpoints. + * + * Identical to {@link OrderResponse} plus {@link #guestToken}, which the + * client needs to (a) build the payment page URL and (b) look up the + * order again without an account. Token is returned only on create and + * by-token lookup — it is never re-exposed by order ID alone. + */ +public record GuestOrderResponse( + UUID id, + String plate, + String letterText, + String status, + String trackingId, + BigDecimal amountPaid, + Instant createdAt, + UUID guestToken +) {} diff --git a/backend/src/main/java/se/bilhalsning/entity/Order.java b/backend/src/main/java/se/bilhalsning/entity/Order.java index 9438da7..d3cbdd2 100644 --- a/backend/src/main/java/se/bilhalsning/entity/Order.java +++ b/backend/src/main/java/se/bilhalsning/entity/Order.java @@ -21,7 +21,12 @@ public class Order { @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) private UUID id; - @Column(name = "user_id", nullable = false, columnDefinition = "uuid") + /** + * Null for guest orders (no registered user). FK to {@code users(id)} + * is still in place — NULL is FK-legal. Either {@code userId} or + * {@code guestToken} is set; never both, never neither. + */ + @Column(name = "user_id", columnDefinition = "uuid") private UUID userId; @ManyToOne(fetch = FetchType.LAZY) @@ -55,11 +60,31 @@ public class Order { @Column(name = "updated_at", nullable = false) private Instant updatedAt; + /** + * Guest contact email. Stored at order create time so a magic link + * (carrying {@code guestToken}) can be emailed to the customer later. + */ + @Column(name = "guest_email", length = 255) + private String guestEmail; + + /** + * Opaque UUID v4 token. The only credential a guest holds. Used as + * the path variable on all {@code /api/guest-orders/{token}/...} + * endpoints. Generated in {@link #onCreate()} for guest orders only. + */ + @Column(name = "guest_token", columnDefinition = "uuid") + private UUID guestToken; + @PrePersist void onCreate() { if (this.id == null) { this.id = UUID.randomUUID(); } + // Guest orders (no userId) get a token for unauthenticated lookup. + // User-owned orders never get one — they go through JWT + userId. + if (this.guestToken == null && this.userId == null) { + this.guestToken = UUID.randomUUID(); + } Instant now = Instant.now(); if (this.createdAt == null) { this.createdAt = now; @@ -159,4 +184,20 @@ public class Order { public Instant getUpdatedAt() { return updatedAt; } + + public String getGuestEmail() { + return guestEmail; + } + + public void setGuestEmail(String guestEmail) { + this.guestEmail = guestEmail; + } + + public UUID getGuestToken() { + return guestToken; + } + + public void setGuestToken(UUID guestToken) { + this.guestToken = guestToken; + } } diff --git a/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java b/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java index 68441a3..bfa9337 100644 --- a/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java +++ b/backend/src/main/java/se/bilhalsning/repository/OrderRepository.java @@ -21,4 +21,8 @@ public interface OrderRepository extends JpaRepository { @EntityGraph(attributePaths = {"user"}) Optional findWithUserById(UUID id); + + // Guest checkout — looks up by opaque token, no JWT. Partial-unique + // index on (guest_token) WHERE NOT NULL enforces uniqueness on writes. + Optional findByGuestToken(UUID guestToken); } diff --git a/backend/src/main/java/se/bilhalsning/service/OrderService.java b/backend/src/main/java/se/bilhalsning/service/OrderService.java index fccdc11..34528e9 100644 --- a/backend/src/main/java/se/bilhalsning/service/OrderService.java +++ b/backend/src/main/java/se/bilhalsning/service/OrderService.java @@ -27,6 +27,56 @@ public class OrderService { return orderRepository.save(order); } + /** + * Guest checkout — creates an order with no registered user. The + * {@code guestToken} is generated in {@link Order#onCreate()} so the + * caller does not need to handle token creation logic. Returned + * order's {@link Order#getGuestToken()} is the only credential the + * client receives. + */ + public Order createGuestOrder(String plate, String letterText, String email) { + Order order = new Order(); + // userId stays null — guest order. guestToken auto-generated in PrePersist. + order.setGuestEmail(email == null ? null : email.trim().toLowerCase()); + order.setPlate(plate.toUpperCase().trim()); + order.setLetterText(letterText); + order.setStatus(OrderStatus.PENDING_PAYMENT); + return orderRepository.save(order); + } + + /** + * Guest-path order lookup by opaque token. Never exposes user-owned + * orders via this path: if a guest token resolves to an order with a + * non-null {@code userId} (corrupted data, manual SQL insert, etc.), + * treat as not-found rather than leak the order. + */ + public Order getOrderByGuestToken(UUID guestToken) { + Order order = orderRepository.findByGuestToken(guestToken) + .orElseThrow(() -> new OrderNotFoundException(guestToken)); + if (order.getUserId() != null) { + // Token points at a user-owned order — refuse to serve it. + throw new OrderNotFoundException(guestToken); + } + return order; + } + + /** + * Honor-system payment confirmation for guest orders. Mirrors + * {@link #confirmPayment(UUID, UUID)} but authenticates via the + * guest token instead of {@code userId}. + */ + public Order confirmGuestPayment(UUID guestToken) { + Order order = getOrderByGuestToken(guestToken); + if (order.getStatus() != OrderStatus.PENDING_PAYMENT) { + throw new InvalidOrderStateException( + "Beställningen kan inte ändras i detta tillstånd"); + } + order.setStatus(OrderStatus.PROCESSING); + Order saved = orderRepository.save(order); + orderNotificationService.notifyOrderProcessing(saved); + return saved; + } + public List getOrdersByUserId(UUID userId) { return orderRepository.findByUserIdOrderByCreatedAtDesc(userId); } diff --git a/backend/src/main/resources/db/migration/V12__add_guest_order_columns.sql b/backend/src/main/resources/db/migration/V12__add_guest_order_columns.sql new file mode 100644 index 0000000..f508fed --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__add_guest_order_columns.sql @@ -0,0 +1,24 @@ +-- Allows orders without a registered user (guest checkout). +-- Users can place and pay for letters without creating an account. +-- +-- user_id: previously NOT NULL — drop the constraint so guest orders +-- can be created without a registered user. The FK stays in +-- place (NULL user_id is FK-legal). +-- guest_email: contact address for the guest. Used to send the magic +-- link that lets them revisit their order status. +-- guest_token: opaque UUID v4 — the only credential a guest has. Acts +-- as their session token for order lookup + payment confirm. + +ALTER TABLE orders ALTER COLUMN user_id DROP NOT NULL; +ALTER TABLE orders ADD COLUMN guest_email VARCHAR(255); +ALTER TABLE orders ADD COLUMN guest_token UUID; + +-- Partial unique index: only enforce uniqueness on non-NULL tokens. +-- Multiple NULLs allowed — existing user-owned orders have no token, +-- and that's fine. +CREATE UNIQUE INDEX idx_orders_guest_token + ON orders(guest_token) + WHERE guest_token IS NOT NULL; +CREATE INDEX idx_orders_guest_email + ON orders(guest_email) + WHERE guest_email IS NOT NULL; diff --git a/frontend/src/api/guestOrders.ts b/frontend/src/api/guestOrders.ts new file mode 100644 index 0000000..4b8e3a6 --- /dev/null +++ b/frontend/src/api/guestOrders.ts @@ -0,0 +1,39 @@ +import { request } from './client' + +/** + * Guest order — placed and paid for without an account. {@link guestToken} + * is the only credential the client holds; it is returned only on create + * and from a by-token lookup. Pass it as the query string on the next + * page so a refresh keeps the session alive. + */ +export interface GuestOrder { + id: string + plate: string + letterText: string + status: string + trackingId: string | null + amountPaid: number | null + createdAt: string + guestToken: string +} + +export function createGuestOrder( + plate: string, + letterText: string, + email: string, +): Promise { + return request('/guest-orders', { + method: 'POST', + body: JSON.stringify({ plate, letterText, email }), + }) +} + +export function fetchGuestOrder(token: string): Promise { + return request(`/guest-orders/${token}`) +} + +export function payGuestOrder(token: string): Promise { + return request(`/guest-orders/${token}/pay`, { + method: 'POST', + }) +} diff --git a/frontend/src/pages/GuestCheckoutPage.vue b/frontend/src/pages/GuestCheckoutPage.vue new file mode 100644 index 0000000..8a625fa --- /dev/null +++ b/frontend/src/pages/GuestCheckoutPage.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/frontend/src/pages/GuestOrderPage.vue b/frontend/src/pages/GuestOrderPage.vue new file mode 100644 index 0000000..5cbd9a4 --- /dev/null +++ b/frontend/src/pages/GuestOrderPage.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/frontend/src/pages/GuestPaymentRedirect.vue b/frontend/src/pages/GuestPaymentRedirect.vue new file mode 100644 index 0000000..f3261b2 --- /dev/null +++ b/frontend/src/pages/GuestPaymentRedirect.vue @@ -0,0 +1,386 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7e63f11..cc34a32 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -20,6 +20,9 @@ import OrdersPage from '@/pages/OrdersPage.vue' import EditOrderPage from '@/pages/EditOrderPage.vue' import AdminPage from '@/pages/AdminPage.vue' import PaymentRedirect from '@/pages/PaymentRedirect.vue' +import GuestCheckoutPage from '@/pages/GuestCheckoutPage.vue' +import GuestPaymentRedirect from '@/pages/GuestPaymentRedirect.vue' +import GuestOrderPage from '@/pages/GuestOrderPage.vue' import { useAuthStore } from '@/stores/authStore' import { getActivePinia } from 'pinia' @@ -88,6 +91,24 @@ const router = createRouter({ component: PaymentRedirect, meta: { requiresAuth: true }, }, + { + // Guest checkout — no account required to place and pay for an order. + path: '/gast-bestallning', + name: 'guest-checkout', + component: GuestCheckoutPage, + }, + { + // Guest payment page — token carried in query so refresh keeps the session. + path: '/gast-betalning/:orderId', + name: 'guest-payment', + component: GuestPaymentRedirect, + }, + { + // Magic-link landing — order status by opaque token. + path: '/gast-order/:token', + name: 'guest-order', + component: GuestOrderPage, + }, { path: '/registrera', name: 'register', diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5c35fd0..ca73e74 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,8 +12,12 @@ export default defineConfig({ }, server: { port: 3000, + host: true, proxy: { - '/api': 'http://backend:8080', + // Allow running Vite locally outside Docker (set + // VITE_API_PROXY_TARGET=http://localhost:8080 npm run dev) by pointing + // the proxy at the host port instead of the compose service name. + '/api': process.env.VITE_API_PROXY_TARGET || 'http://backend:8080', }, }, preview: {