diff --git a/backend/src/main/java/de/fete/adapter/in/web/SpaController.java b/backend/src/main/java/de/fete/adapter/in/web/SpaController.java new file mode 100644 index 0000000..f6159af --- /dev/null +++ b/backend/src/main/java/de/fete/adapter/in/web/SpaController.java @@ -0,0 +1,188 @@ +package de.fete.adapter.in.web; + +import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; +import de.fete.domain.port.in.GetEventUseCase; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +/** Serves the SPA index.html with injected Open Graph and Twitter Card meta-tags. */ +@Controller +public class SpaController { + + private static final String PLACEHOLDER = ""; + private static final int MAX_TITLE_LENGTH = 70; + private static final int MAX_DESCRIPTION_LENGTH = 200; + private static final String GENERIC_TITLE = "fete"; + private static final String GENERIC_DESCRIPTION = + "Privacy-focused event planning. Create and share events without accounts."; + private static final DateTimeFormatter DATE_FORMAT = + DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH); + + private final GetEventUseCase getEventUseCase; + private String htmlTemplate; + + /** Creates a new SpaController. */ + public SpaController(GetEventUseCase getEventUseCase) { + this.getEventUseCase = getEventUseCase; + } + + /** Loads and caches the index.html template at startup. */ + @PostConstruct + void loadTemplate() throws IOException { + var resource = new ClassPathResource("/static/index.html"); + if (resource.exists()) { + htmlTemplate = resource.getContentAsString(StandardCharsets.UTF_8); + } + } + + /** Serves SPA HTML with generic meta-tags for non-event routes. */ + @GetMapping( + value = {"/", "/create", "/events"}, + produces = MediaType.TEXT_HTML_VALUE + ) + @ResponseBody + public String serveGenericPage(HttpServletRequest request) { + if (htmlTemplate == null) { + return ""; + } + String baseUrl = getBaseUrl(request); + return htmlTemplate.replace(PLACEHOLDER, renderTags(buildGenericMeta(baseUrl))); + } + + /** Serves SPA HTML with event-specific meta-tags. */ + @GetMapping( + value = "/events/{token}", + produces = MediaType.TEXT_HTML_VALUE + ) + @ResponseBody + public String serveEventPage(@PathVariable String token, + HttpServletRequest request) { + if (htmlTemplate == null) { + return ""; + } + String baseUrl = getBaseUrl(request); + Map meta = resolveEventMeta(token, baseUrl); + return htmlTemplate.replace(PLACEHOLDER, renderTags(meta)); + } + + // --- Meta-tag composition --- + + private Map buildEventMeta(Event event, String baseUrl) { + var tags = new LinkedHashMap(); + String title = truncateTitle(event.getTitle()); + String description = formatDescription(event); + tags.put("og:title", title); + tags.put("og:description", description); + tags.put("og:url", baseUrl + "/events/" + event.getEventToken().value()); + tags.put("og:type", "website"); + tags.put("og:site_name", GENERIC_TITLE); + tags.put("og:image", baseUrl + "/og-image.png"); + tags.put("twitter:card", "summary"); + tags.put("twitter:title", title); + tags.put("twitter:description", description); + return tags; + } + + private Map buildGenericMeta(String baseUrl) { + var tags = new LinkedHashMap(); + tags.put("og:title", GENERIC_TITLE); + tags.put("og:description", GENERIC_DESCRIPTION); + tags.put("og:url", baseUrl); + tags.put("og:type", "website"); + tags.put("og:site_name", GENERIC_TITLE); + tags.put("og:image", baseUrl + "/og-image.png"); + tags.put("twitter:card", "summary"); + tags.put("twitter:title", GENERIC_TITLE); + tags.put("twitter:description", GENERIC_DESCRIPTION); + return tags; + } + + private Map resolveEventMeta(String token, String baseUrl) { + try { + UUID uuid = UUID.fromString(token); + Optional event = + getEventUseCase.getByEventToken(new EventToken(uuid)); + if (event.isPresent()) { + return buildEventMeta(event.get(), baseUrl); + } + } catch (IllegalArgumentException ignored) { + // Invalid UUID โ€” fall back to generic + } + return buildGenericMeta(baseUrl); + } + + // --- Description formatting --- + + private String truncateTitle(String title) { + if (title.length() <= MAX_TITLE_LENGTH) { + return title; + } + return title.substring(0, MAX_TITLE_LENGTH - 3) + "..."; + } + + private String formatDescription(Event event) { + ZonedDateTime zoned = event.getDateTime().atZoneSameInstant(event.getTimezone()); + var sb = new StringBuilder(); + sb.append("๐Ÿ“… ").append(zoned.format(DATE_FORMAT)); + + if (event.getLocation() != null && !event.getLocation().isBlank()) { + sb.append(" ยท ๐Ÿ“ ").append(event.getLocation()); + } + + if (event.getDescription() != null && !event.getDescription().isBlank()) { + sb.append(" โ€” ").append(event.getDescription()); + } + + String result = sb.toString(); + if (result.length() > MAX_DESCRIPTION_LENGTH) { + return result.substring(0, MAX_DESCRIPTION_LENGTH - 3) + "..."; + } + return result; + } + + // --- HTML rendering --- + + private String renderTags(Map tags) { + var sb = new StringBuilder(); + for (var entry : tags.entrySet()) { + String key = entry.getKey(); + String value = escapeHtml(entry.getValue()); + String attr = key.startsWith("twitter:") ? "name" : "property"; + sb.append("\n"); + } + return sb.toString().stripTrailing(); + } + + private String escapeHtml(String input) { + return input + .replace("&", "&") + .replace("\"", """) + .replace("<", "<") + .replace(">", ">"); + } + + private String getBaseUrl(HttpServletRequest request) { + return ServletUriComponentsBuilder.fromRequestUri(request) + .replacePath("") + .build() + .toUriString(); + } +} diff --git a/backend/src/main/java/de/fete/config/WebConfig.java b/backend/src/main/java/de/fete/config/WebConfig.java index 79c8ee9..f5b0613 100644 --- a/backend/src/main/java/de/fete/config/WebConfig.java +++ b/backend/src/main/java/de/fete/config/WebConfig.java @@ -1,21 +1,17 @@ package de.fete.config; -import java.io.IOException; import java.time.Clock; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.Resource; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; -import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import org.springframework.web.servlet.resource.PathResourceResolver; -/** Configures API path prefix and SPA static resource serving. */ +/** Configures API path prefix. Static resources served by default Spring Boot handler. */ @Configuration public class WebConfig implements WebMvcConfigurer { + /** Provides a system clock bean for time-dependent services. */ @Bean Clock clock() { return Clock.systemDefaultZone(); @@ -25,23 +21,4 @@ public class WebConfig implements WebMvcConfigurer { public void configurePathMatch(PathMatchConfigurer configurer) { configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class)); } - - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry.addResourceHandler("/**") - .addResourceLocations("classpath:/static/") - .resourceChain(true) - .addResolver(new PathResourceResolver() { - @Override - protected Resource getResource(String resourcePath, - Resource location) throws IOException { - Resource requested = location.createRelative(resourcePath); - if (requested.exists() && requested.isReadable()) { - return requested; - } - Resource index = new ClassPathResource("/static/index.html"); - return (index.exists() && index.isReadable()) ? index : null; - } - }); - } } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 899c8af..4a17cef 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -7,6 +7,9 @@ spring.jpa.open-in-view=false # Liquibase spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml +# Proxy headers +server.forward-headers-strategy=framework + # Actuator management.endpoints.web.exposure.include=health management.endpoint.health.show-details=never diff --git a/backend/src/test/java/de/fete/adapter/in/web/SpaControllerTest.java b/backend/src/test/java/de/fete/adapter/in/web/SpaControllerTest.java new file mode 100644 index 0000000..aedeecc --- /dev/null +++ b/backend/src/test/java/de/fete/adapter/in/web/SpaControllerTest.java @@ -0,0 +1,281 @@ +package de.fete.adapter.in.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.fete.TestcontainersConfig; +import de.fete.adapter.out.persistence.EventJpaEntity; +import de.fete.adapter.out.persistence.EventJpaRepository; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfig.class) +class SpaControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private EventJpaRepository eventJpaRepository; + + // --- Phase 2: Base functionality --- + + @Test + void rootServesHtml() throws Exception { + mockMvc.perform(get("/").accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)); + } + + @Test + void rootHtmlDoesNotContainPlaceholder() throws Exception { + String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).doesNotContain(""); + } + + @Test + void createRouteServesHtml() throws Exception { + mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)); + } + + @Test + void eventsRouteServesHtml() throws Exception { + mockMvc.perform(get("/events").accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)); + } + + // --- Phase 4 (US2): Generic OG meta-tags --- + + @Test + void rootContainsGenericOgTitle() throws Exception { + String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).contains("og:title"); + assertThat(html).contains("content=\"fete\""); + } + + @Test + void createRouteContainsGenericOgDescription() throws Exception { + String html = mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).contains("og:description"); + assertThat(html).contains("Privacy-focused event planning"); + } + + @Test + void unknownRouteReturns404() throws Exception { + mockMvc.perform(get("/unknown/path").accept(MediaType.TEXT_HTML)) + .andExpect(status().isNotFound()); + } + + // --- Phase 5 (US3): Twitter Card meta-tags --- + + @Test + void eventRouteContainsTwitterCardTags() throws Exception { + EventJpaEntity event = seedEvent( + "Twitter Test", "Testing cards", + "Europe/Berlin", "Berlin", LocalDate.now().plusDays(30)); + + String html = mockMvc.perform( + get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).contains("twitter:card"); + assertThat(html).contains("twitter:title"); + assertThat(html).contains("twitter:description"); + } + + @Test + void genericRouteContainsTwitterCardTags() throws Exception { + String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).contains("twitter:card"); + assertThat(html).contains("content=\"summary\""); + } + + // --- Phase 3 (US1): Event-specific OG meta-tags --- + + @Test + void eventRouteContainsEventSpecificOgTitle() throws Exception { + EventJpaEntity event = seedEvent( + "Birthday Party", "Come celebrate!", + "Europe/Berlin", "Berlin", LocalDate.now().plusDays(30)); + + String html = mockMvc.perform( + get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).contains("og:title"); + assertThat(html).contains("Birthday Party"); + } + + @Test + void eventRouteContainsOgDescription() throws Exception { + EventJpaEntity event = seedEvent( + "BBQ", "Bring drinks!", + "Europe/Berlin", "Central Park", LocalDate.now().plusDays(30)); + + String html = mockMvc.perform( + get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).contains("og:description"); + assertThat(html).contains("Central Park"); + assertThat(html).contains("Bring drinks!"); + } + + @Test + void eventRouteContainsOgUrl() throws Exception { + EventJpaEntity event = seedEvent( + "Party", null, + "Europe/Berlin", null, LocalDate.now().plusDays(30)); + + String html = mockMvc.perform( + get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).contains("og:url"); + assertThat(html).contains("/events/" + event.getEventToken()); + } + + @Test + void eventRouteContainsOgImage() throws Exception { + EventJpaEntity event = seedEvent( + "Party", null, + "Europe/Berlin", null, LocalDate.now().plusDays(30)); + + String html = mockMvc.perform( + get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).contains("og:image"); + assertThat(html).contains("/og-image.png"); + } + + @Test + void unknownEventTokenFallsBackToGenericMeta() throws Exception { + String html = mockMvc.perform( + get("/events/" + UUID.randomUUID()).accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).contains("og:title"); + assertThat(html).contains("content=\"fete\""); + } + + // --- HTML escaping --- + + @Test + void specialCharactersAreHtmlEscaped() throws Exception { + EventJpaEntity event = seedEvent( + "Tom & Jerry's \"Party\"", "Fun & more", + "Europe/Berlin", "O'Brien's Pub", LocalDate.now().plusDays(30)); + + String html = mockMvc.perform( + get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).contains("Tom & Jerry"); + assertThat(html).contains("& more"); + assertThat(html).contains("<times>"); + assertThat(html).doesNotContain("content=\"Tom & Jerry"); + } + + // --- Title truncation --- + + @Test + void longTitleIsTruncatedTo70Chars() throws Exception { + String longTitle = "A".repeat(80); + EventJpaEntity event = seedEvent( + longTitle, "Desc", + "Europe/Berlin", null, LocalDate.now().plusDays(30)); + + String html = mockMvc.perform( + get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).contains("A".repeat(67) + "..."); + assertThat(html).doesNotContain("A".repeat(68)); + } + + // --- Description formatting --- + + @Test + void eventWithoutLocationOmitsPinEmoji() throws Exception { + EventJpaEntity event = seedEvent( + "Online Meetup", "Virtual gathering", + "Europe/Berlin", null, LocalDate.now().plusDays(30)); + + String html = mockMvc.perform( + get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).doesNotContain("๐Ÿ“"); + } + + @Test + void eventWithoutDescriptionOmitsDash() throws Exception { + EventJpaEntity event = seedEvent( + "Silent Event", null, + "Europe/Berlin", "Berlin", LocalDate.now().plusDays(30)); + + String html = mockMvc.perform( + get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(html).contains("๐Ÿ“…"); + assertThat(html).contains("Berlin"); + assertThat(html).doesNotContain(" โ€” "); + } + + private EventJpaEntity seedEvent( + String title, String description, String timezone, + String location, LocalDate expiryDate) { + var entity = new EventJpaEntity(); + entity.setEventToken(UUID.randomUUID()); + entity.setOrganizerToken(UUID.randomUUID()); + entity.setTitle(title); + entity.setDescription(description); + entity.setDateTime( + OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))); + entity.setTimezone(timezone); + entity.setLocation(location); + entity.setExpiryDate(expiryDate); + entity.setCreatedAt(OffsetDateTime.now()); + return eventJpaRepository.save(entity); + } +} diff --git a/backend/src/test/java/de/fete/config/WebConfigTest.java b/backend/src/test/java/de/fete/config/WebConfigTest.java index 2170412..066aa30 100644 --- a/backend/src/test/java/de/fete/config/WebConfigTest.java +++ b/backend/src/test/java/de/fete/config/WebConfigTest.java @@ -29,8 +29,10 @@ class WebConfigTest { @Test void apiPrefixNotAccessibleWithoutIt() throws Exception { - // /events without /api prefix should not resolve to the API endpoint - mockMvc.perform(get("/events")) - .andExpect(status().isNotFound()); + // /events without /api prefix should not resolve to the REST API endpoint; + // it is served by SpaController as HTML instead + mockMvc.perform(get("/events") + .accept("text/html")) + .andExpect(status().isOk()); } } diff --git a/backend/src/test/resources/static/index.html b/backend/src/test/resources/static/index.html new file mode 100644 index 0000000..183b15c --- /dev/null +++ b/backend/src/test/resources/static/index.html @@ -0,0 +1,13 @@ + + + + + + + + fete + + +
+ + diff --git a/frontend/index.html b/frontend/index.html index 6837358..6dd842e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,7 @@ + fete diff --git a/frontend/public/og-image.png b/frontend/public/og-image.png new file mode 100644 index 0000000..daa1a94 Binary files /dev/null and b/frontend/public/og-image.png differ diff --git a/specs/012-link-preview/checklists/requirements.md b/specs/012-link-preview/checklists/requirements.md new file mode 100644 index 0000000..2ae9254 --- /dev/null +++ b/specs/012-link-preview/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Link Preview (Open Graph Meta-Tags) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-09 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- Assumptions section documents the key technical consideration (SPA vs. server-rendered meta-tags) without prescribing a solution. +- `og:image` explicitly deferred to future scope. diff --git a/specs/012-link-preview/contracts/html-meta-tags.md b/specs/012-link-preview/contracts/html-meta-tags.md new file mode 100644 index 0000000..3c6c00c --- /dev/null +++ b/specs/012-link-preview/contracts/html-meta-tags.md @@ -0,0 +1,98 @@ +# Contract: HTML Meta-Tags + +**Feature**: 012-link-preview | **Date**: 2026-03-09 + +## Overview + +This feature does not add new REST API endpoints. The contract is the HTML meta-tag structure injected into the server-rendered `index.html`. + +## Meta-Tag Contract: Event Pages + +For requests to `/events/{eventToken}` where the event exists: + +```html + + + + + + + + + + + + +``` + +### Description Format + +Full event data: +``` +๐Ÿ“… Saturday, March 15, 2026 at 7:00 PM ยท ๐Ÿ“ Berlin โ€” First 200 chars of description... +``` + +No location: +``` +๐Ÿ“… Saturday, March 15, 2026 at 7:00 PM โ€” First 200 chars of description... +``` + +No description: +``` +๐Ÿ“… Saturday, March 15, 2026 at 7:00 PM ยท ๐Ÿ“ Berlin +``` + +No location, no description: +``` +๐Ÿ“… Saturday, March 15, 2026 at 7:00 PM +``` + +### Title Truncation + +- Titles โ‰ค 70 characters: used as-is. +- Titles > 70 characters: truncated to 67 characters + "..." + +### HTML Escaping + +All meta-tag `content` values MUST be HTML-escaped: +- `"` โ†’ `"` +- `&` โ†’ `&` +- `<` โ†’ `<` +- `>` โ†’ `>` + +## Meta-Tag Contract: Non-Event Pages + +For requests to `/`, `/create`, or any other non-event, non-API, non-static route: + +```html + + + + + + + + + + + + +``` + +## Meta-Tag Contract: Event Not Found + +For requests to `/events/{eventToken}` where the event does NOT exist, fall back to generic meta-tags (same as non-event pages). The Vue SPA will handle the 404 UI client-side. + +## Injection Mechanism + +The `index.html` template contains a placeholder: + +```html + + ... + + ... + +``` + +The server replaces `` with the generated meta-tag block before sending the response. diff --git a/specs/012-link-preview/data-model.md b/specs/012-link-preview/data-model.md new file mode 100644 index 0000000..55ca34a --- /dev/null +++ b/specs/012-link-preview/data-model.md @@ -0,0 +1,83 @@ +# Data Model: Link Preview (Open Graph Meta-Tags) + +**Feature**: 012-link-preview | **Date**: 2026-03-09 + +## Overview + +This feature does NOT introduce new database entities. It reads existing event data and projects it into HTML meta-tags. The "model" here is the meta-tag value object used during HTML generation. + +## Meta-Tag Value Objects + +### OpenGraphMeta + +Represents the set of Open Graph meta-tags to inject into the HTML response. + +| Field | Type | Source | Rules | +|---|---|---|---| +| `title` | String | Event title or "fete" | Max 70 chars, truncated with "..." | +| `description` | String | Composed from event fields or generic text | Max 200 chars | +| `url` | String | Canonical URL from request | Absolute URL | +| `type` | String | Always "website" | Constant | +| `siteName` | String | Always "fete" | Constant | +| `image` | String | Static brand image URL | Absolute URL to `/og-image.png` | + +### TwitterCardMeta + +| Field | Type | Source | Rules | +|---|---|---|---| +| `card` | String | Always "summary" | Constant | +| `title` | String | Same as OG title | Max 70 chars | +| `description` | String | Same as OG description | Max 200 chars | + +## Data Flow + +``` +Request for /events/{token} + โ”‚ + โ–ผ +LinkPreviewController + โ”‚ + โ”œโ”€โ”€ Resolve event token โ†’ Event domain object (existing EventRepository) + โ”‚ + โ”œโ”€โ”€ Build OpenGraphMeta from Event fields: + โ”‚ title โ† event.title (truncated) + โ”‚ description โ† formatDescription(event.dateTime, event.timezone, event.location, event.description) + โ”‚ url โ† request base URL + /events/{token} + โ”‚ image โ† request base URL + /og-image.png + โ”‚ + โ”œโ”€โ”€ Build TwitterCardMeta (mirrors OG values) + โ”‚ + โ”œโ”€โ”€ Inject meta-tags into cached index.html template + โ”‚ + โ””โ”€โ”€ Return modified HTML + +Request for / or /create (non-event pages) + โ”‚ + โ–ผ +LinkPreviewController + โ”‚ + โ”œโ”€โ”€ Build generic OpenGraphMeta: + โ”‚ title โ† "fete" + โ”‚ description โ† "Privacy-focused event planning. Create and share events without accounts." + โ”‚ url โ† request URL + โ”‚ image โ† request base URL + /og-image.png + โ”‚ + โ”œโ”€โ”€ Build generic TwitterCardMeta + โ”‚ + โ”œโ”€โ”€ Inject meta-tags into cached index.html template + โ”‚ + โ””โ”€โ”€ Return modified HTML +``` + +## Existing Entities Used (Read-Only) + +### Event (from `de.fete.domain.model.Event`) + +| Field | Used For | +|---|---| +| `title` | `og:title`, `twitter:title` | +| `description` | Part of `og:description`, `twitter:description` | +| `dateTime` | Part of `og:description` (formatted) | +| `timezone` | Date formatting context | +| `location` | Part of `og:description` | +| `eventToken` | URL construction | diff --git a/specs/012-link-preview/plan.md b/specs/012-link-preview/plan.md new file mode 100644 index 0000000..3d4d1da --- /dev/null +++ b/specs/012-link-preview/plan.md @@ -0,0 +1,83 @@ +# Implementation Plan: Link Preview (Open Graph Meta-Tags) + +**Branch**: `012-link-preview` | **Date**: 2026-03-09 | **Spec**: `specs/012-link-preview/spec.md` +**Input**: Feature specification from `/specs/012-link-preview/spec.md` + +## Summary + +Inject Open Graph and Twitter Card meta-tags into the server-rendered HTML so that shared event links display rich preview cards in messengers and on social media. The Spring Boot backend replaces its current static SPA fallback with a controller that dynamically injects event-specific or generic meta-tags into the cached `index.html` template before serving it. + +## Technical Context + +**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend โ€” minimal changes) +**Primary Dependencies**: Spring Boot 3.5.x (existing), Vue 3 (existing) โ€” no new dependencies +**Storage**: PostgreSQL via JPA (existing event data, read-only access) +**Testing**: JUnit 5 + Spring MockMvc (backend), Playwright (E2E) +**Target Platform**: Self-hosted Docker container (Linux) +**Project Type**: Web application (SPA + REST API) +**Performance Goals**: N/A โ€” meta-tag injection adds negligible overhead (<1ms string replacement) +**Constraints**: Meta-tags MUST be in initial HTML response (no client-side JS injection). No external services or CDNs. +**Scale/Scope**: Affects all HTML page responses. No new database tables or API endpoints. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|---|---|---| +| I. Privacy by Design | โœ… PASS | No tracking, analytics, or external services. Meta-tags contain only public event info (title, date, location). No PII exposed. `og:image` is a self-hosted static asset. | +| II. Test-Driven Methodology | โœ… PLAN | Unit tests for meta-tag generation, integration tests for controller, E2E tests for full HTML verification. TDD enforced. | +| III. API-First Development | โœ… N/A | No new API endpoints. This feature modifies HTML serving, not the REST API. Existing OpenAPI spec unchanged. | +| IV. Simplicity & Quality | โœ… PASS | Simple string replacement in cached HTML template. No SSR framework, no prerendering service, no user-agent sniffing. Minimal moving parts. | +| V. Dependency Discipline | โœ… PASS | Zero new dependencies. Uses only Spring Boot's existing capabilities. | +| VI. Accessibility | โœ… N/A | Meta-tags are invisible to users. No UI changes. | + +**Gate result: PASS** โ€” No violations. No complexity tracking needed. + +## Project Structure + +### Documentation (this feature) + +```text +specs/012-link-preview/ +โ”œโ”€โ”€ plan.md # This file +โ”œโ”€โ”€ research.md # Phase 0 output โ€” technical decisions +โ”œโ”€โ”€ data-model.md # Phase 1 output โ€” meta-tag value objects +โ”œโ”€โ”€ quickstart.md # Phase 1 output โ€” implementation guide +โ”œโ”€โ”€ contracts/ +โ”‚ โ””โ”€โ”€ html-meta-tags.md # Phase 1 output โ€” meta-tag HTML contract +โ””โ”€โ”€ tasks.md # Phase 2 output (created by /speckit.tasks) +``` + +### Source Code (repository root) + +```text +backend/ +โ”œโ”€โ”€ src/main/java/de/fete/ +โ”‚ โ”œโ”€โ”€ adapter/in/web/ +โ”‚ โ”‚ โ””โ”€โ”€ SpaController.java # NEW โ€” serves index.html with injected meta-tags +โ”‚ โ”œโ”€โ”€ application/ +โ”‚ โ”‚ โ””โ”€โ”€ OpenGraphService.java # NEW โ€” composes meta-tag values from event data +โ”‚ โ””โ”€โ”€ config/ +โ”‚ โ””โ”€โ”€ WebConfig.java # MODIFIED โ€” remove PathResourceResolver SPA fallback +โ”œโ”€โ”€ src/main/resources/ +โ”‚ โ””โ”€โ”€ application.properties # MODIFIED โ€” add forward-headers-strategy +โ””โ”€โ”€ src/test/java/de/fete/ + โ”œโ”€โ”€ adapter/in/web/ + โ”‚ โ””โ”€โ”€ SpaControllerTest.java # NEW โ€” integration tests + โ””โ”€โ”€ application/ + โ””โ”€โ”€ OpenGraphServiceTest.java # NEW โ€” unit tests + +frontend/ +โ”œโ”€โ”€ index.html # MODIFIED โ€” add placeholder +โ”œโ”€โ”€ public/ +โ”‚ โ””โ”€โ”€ og-image.png # NEW โ€” brand image for og:image (1200x630) +โ””โ”€โ”€ e2e/ + โ””โ”€โ”€ link-preview.spec.ts # NEW โ€” E2E tests +``` + +**Structure Decision**: Web application structure (existing). Backend changes in adapter/web and application layers. Frontend changes minimal (HTML placeholder + static asset). + +## Complexity Tracking + +> No violations โ€” section intentionally empty. diff --git a/specs/012-link-preview/quickstart.md b/specs/012-link-preview/quickstart.md new file mode 100644 index 0000000..2175932 --- /dev/null +++ b/specs/012-link-preview/quickstart.md @@ -0,0 +1,57 @@ +# Quickstart: Link Preview (Open Graph Meta-Tags) + +**Feature**: 012-link-preview | **Date**: 2026-03-09 + +## What This Feature Does + +Injects Open Graph and Twitter Card meta-tags into the HTML response so that shared links display rich preview cards in messengers (WhatsApp, Telegram, Signal, etc.) and on social media (Twitter/X). + +## How It Works + +1. **All HTML page requests** go through a new `SpaController` (replaces the current `PathResourceResolver` SPA fallback). +2. The controller reads the compiled `index.html` template once at startup and caches it. +3. For event pages (`/events/{token}`): fetches event data, generates event-specific meta-tags, injects them into the HTML. +4. For non-event pages: injects generic fete branding meta-tags. +5. Static files (`/assets/*`, `/favicon.svg`, `/og-image.png`) continue to be served directly by Spring Boot's default static resource handler. + +## Key Files to Create/Modify + +### Backend (New) + +| File | Purpose | +|---|---| +| `SpaController.java` | Controller handling all non-API/non-static HTML requests, injecting meta-tags | +| `OpenGraphService.java` | Service composing meta-tag values from event data | +| `MetaTagRenderer.java` | Utility rendering meta-tag value objects into HTML `` strings | + +### Backend (Modified) + +| File | Change | +|---|---| +| `WebConfig.java` | Remove `PathResourceResolver` SPA fallback (replaced by `SpaController`) | +| `application.properties` | Add `server.forward-headers-strategy=framework` for correct URL construction behind proxies | + +### Frontend (Modified) + +| File | Change | +|---|---| +| `index.html` | Add `` placeholder comment in `` | + +### Static Assets (New) + +| File | Purpose | +|---|---| +| `frontend/public/og-image.png` | Brand image for `og:image` (1200x630 PNG) | + +## Testing Strategy + +- **Unit tests**: `OpenGraphService` โ€” verify meta-tag values for various event states (full data, no location, no description, long title, special characters). +- **Unit tests**: `MetaTagRenderer` โ€” verify HTML escaping, correct meta-tag format. +- **Integration tests**: `SpaController` โ€” verify correct HTML response with meta-tags for event URLs, generic URLs, and 404 events. +- **E2E tests**: Fetch event page HTML without JavaScript, parse meta-tags, verify values match event data. + +## Local Development Notes + +- In dev mode (Vite dev server), meta-tags won't be injected since Vite serves its own `index.html`. This is expected โ€” meta-tag injection only works when the backend serves the frontend. +- To test locally: build the frontend (`npm run build`), copy `dist/` contents to `backend/src/main/resources/static/`, then run the backend. +- Alternatively, test via the Docker build which assembles everything automatically. diff --git a/specs/012-link-preview/research.md b/specs/012-link-preview/research.md new file mode 100644 index 0000000..7533ee3 --- /dev/null +++ b/specs/012-link-preview/research.md @@ -0,0 +1,115 @@ +# Research: Link Preview (Open Graph Meta-Tags) + +**Feature**: 012-link-preview | **Date**: 2026-03-09 + +## R1: How to Serve Dynamic Meta-Tags from a Vue SPA + +### Problem + +Vue SPA serves a single `index.html` for all routes via Spring Boot's `PathResourceResolver` fallback in `WebConfig.java`. Social media crawlers (WhatsApp, Telegram, Signal, Twitter/X) do NOT execute JavaScript โ€” they only read the initial HTML response. The current `index.html` contains no Open Graph meta-tags. + +### Decision: Server-Side HTML Template Injection + +Intercept HTML page requests in the Spring Boot backend. Before serving `index.html`, parse the route, fetch event data if applicable, and inject `` tags into the `` section. + +### Rationale + +- **No new dependencies**: Uses Spring Boot's existing resource serving + simple string manipulation. +- **No SSR framework needed**: Avoids adding Nuxt, Vite SSR, or a prerendering service. +- **Universal**: Works for all clients (not just crawlers), improving SEO for all visitors. +- **Simple**: The backend already serves `index.html` for all non-API/non-static routes. We just need to modify *what* HTML is returned. + +### Alternatives Considered + +| Alternative | Rejected Because | +|---|---| +| **Vue SSR (Nuxt/Vite SSR)** | Massive architectural change. Overkill for injecting a few meta-tags. Violates KISS. | +| **Prerendering service (prerender.io, rendertron)** | External dependency that may phone home. Violates Privacy by Design. Adds operational complexity. | +| **User-agent sniffing** | Fragile โ€” crawler UA strings change frequently. Serving different content to crawlers vs. users is considered cloaking by some search engines. | +| **Static prerendering at build time** | Events are dynamic โ€” created at runtime. Cannot prerender at build time. | +| **`