From 8e6a5a26ac0fcfba4a255d3f1a5f5329acb01057 Mon Sep 17 00:00:00 2001 From: Matheus Andre Date: Thu, 8 Jan 2026 00:46:24 -0300 Subject: [PATCH] refactor: Handle dual-writes when processing incoming messages --- .../matheuscruz/domain/InboundMessage.java | 32 +++++++++ .../domain/InboundMessageRepository.java | 8 +++ .../java/dev/matheuscruz/domain/Outbox.java | 65 +++++++++++++++++++ .../matheuscruz/domain/OutboxRepository.java | 13 ++++ .../matheuscruz/infra/queue/OutboxPoller.java | 62 ++++++++++++++++++ .../java/dev/matheuscruz/infra/queue/SQS.java | 57 ++++++++++++---- timeless-api/src/main/webui/package-lock.json | 53 ++++++--------- 7 files changed, 247 insertions(+), 43 deletions(-) create mode 100644 timeless-api/src/main/java/dev/matheuscruz/domain/InboundMessage.java create mode 100644 timeless-api/src/main/java/dev/matheuscruz/domain/InboundMessageRepository.java create mode 100644 timeless-api/src/main/java/dev/matheuscruz/domain/Outbox.java create mode 100644 timeless-api/src/main/java/dev/matheuscruz/domain/OutboxRepository.java create mode 100644 timeless-api/src/main/java/dev/matheuscruz/infra/queue/OutboxPoller.java diff --git a/timeless-api/src/main/java/dev/matheuscruz/domain/InboundMessage.java b/timeless-api/src/main/java/dev/matheuscruz/domain/InboundMessage.java new file mode 100644 index 0000000..090d509 --- /dev/null +++ b/timeless-api/src/main/java/dev/matheuscruz/domain/InboundMessage.java @@ -0,0 +1,32 @@ +package dev.matheuscruz.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "inbound_messages") +public class InboundMessage { + + @Id + private String messageId; + + private Instant processedAt; + + protected InboundMessage() { + } + + public InboundMessage(String messageId) { + this.messageId = messageId; + this.processedAt = Instant.now(); + } + + public String getMessageId() { + return messageId; + } + + public Instant getProcessedAt() { + return processedAt; + } +} diff --git a/timeless-api/src/main/java/dev/matheuscruz/domain/InboundMessageRepository.java b/timeless-api/src/main/java/dev/matheuscruz/domain/InboundMessageRepository.java new file mode 100644 index 0000000..02a8034 --- /dev/null +++ b/timeless-api/src/main/java/dev/matheuscruz/domain/InboundMessageRepository.java @@ -0,0 +1,8 @@ +package dev.matheuscruz.domain; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class InboundMessageRepository implements PanacheRepositoryBase { +} diff --git a/timeless-api/src/main/java/dev/matheuscruz/domain/Outbox.java b/timeless-api/src/main/java/dev/matheuscruz/domain/Outbox.java new file mode 100644 index 0000000..dd94b8c --- /dev/null +++ b/timeless-api/src/main/java/dev/matheuscruz/domain/Outbox.java @@ -0,0 +1,65 @@ +package dev.matheuscruz.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "outbox") +public class Outbox { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(columnDefinition = "TEXT") + private String payload; + + private String status; + + private Instant createdAt; + + private Instant processedAt; + + protected Outbox() { + } + + public Outbox(String payload) { + this.payload = payload; + this.status = "PENDING"; + this.createdAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getPayload() { + return payload; + } + + public String getStatus() { + return status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getProcessedAt() { + return processedAt; + } + + public void markAsSent() { + this.status = "SENT"; + this.processedAt = Instant.now(); + } + + public void markAsFailed() { + this.status = "FAILED"; + } +} diff --git a/timeless-api/src/main/java/dev/matheuscruz/domain/OutboxRepository.java b/timeless-api/src/main/java/dev/matheuscruz/domain/OutboxRepository.java new file mode 100644 index 0000000..e126391 --- /dev/null +++ b/timeless-api/src/main/java/dev/matheuscruz/domain/OutboxRepository.java @@ -0,0 +1,13 @@ +package dev.matheuscruz.domain; + +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; + +@ApplicationScoped +public class OutboxRepository implements PanacheRepository { + + public List findPending() { + return list("status", "PENDING"); + } +} diff --git a/timeless-api/src/main/java/dev/matheuscruz/infra/queue/OutboxPoller.java b/timeless-api/src/main/java/dev/matheuscruz/infra/queue/OutboxPoller.java new file mode 100644 index 0000000..99ae728 --- /dev/null +++ b/timeless-api/src/main/java/dev/matheuscruz/infra/queue/OutboxPoller.java @@ -0,0 +1,62 @@ +package dev.matheuscruz.infra.queue; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.matheuscruz.domain.Outbox; +import dev.matheuscruz.domain.OutboxRepository; +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import java.util.List; +import java.util.UUID; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; +import software.amazon.awssdk.services.sqs.SqsClient; + +@ApplicationScoped +public class OutboxPoller { + + private static final Logger LOGGER = Logger.getLogger(OutboxPoller.class); + + final OutboxRepository outboxRepository; + final SqsClient sqs; + final String processedMessagesUrl; + final ObjectMapper objectMapper; + + public OutboxPoller(OutboxRepository outboxRepository, SqsClient sqs, + @ConfigProperty(name = "whatsapp.recognized-message.queue-url") String processedMessagesUrl, + ObjectMapper objectMapper) { + this.outboxRepository = outboxRepository; + this.sqs = sqs; + this.processedMessagesUrl = processedMessagesUrl; + this.objectMapper = objectMapper; + } + + @Scheduled(every = "10s") + @Transactional + public void poll() { + List pendingMessages = outboxRepository.findPending(); + if (pendingMessages.isEmpty()) { + return; + } + + LOGGER.infof("Processing %d pending outbox messages", pendingMessages.size()); + + for (Outbox outbox : pendingMessages) { + try { + sendMessage(outbox); + outbox.markAsSent(); + outboxRepository.persist(outbox); + } catch (Exception e) { + LOGGER.error("Failed to send outbox message: " + outbox.getId(), e); + outbox.markAsFailed(); + outboxRepository.persist(outbox); + } + } + } + + private void sendMessage(Outbox outbox) { + sqs.sendMessage(req -> req.messageBody(outbox.getPayload()).messageGroupId("ProcessedMessages") + .messageDeduplicationId(outbox.getId().toString()).queueUrl(processedMessagesUrl)); + } +} diff --git a/timeless-api/src/main/java/dev/matheuscruz/infra/queue/SQS.java b/timeless-api/src/main/java/dev/matheuscruz/infra/queue/SQS.java index 6aae662..45c393a 100644 --- a/timeless-api/src/main/java/dev/matheuscruz/infra/queue/SQS.java +++ b/timeless-api/src/main/java/dev/matheuscruz/infra/queue/SQS.java @@ -3,6 +3,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; +import dev.matheuscruz.domain.InboundMessage; +import dev.matheuscruz.domain.InboundMessageRepository; +import dev.matheuscruz.domain.Outbox; +import dev.matheuscruz.domain.OutboxRepository; import dev.matheuscruz.domain.Record; import dev.matheuscruz.domain.RecordRepository; import dev.matheuscruz.domain.User; @@ -33,6 +37,8 @@ public class SQS { final TextAiService aiService; final RecordRepository recordRepository; final UserRepository userRepository; + final OutboxRepository outboxRepository; + final InboundMessageRepository inboundMessageRepository; final Logger logger = Logger.getLogger(SQS.class); private static final ObjectReader INCOMING_MESSAGE_READER = new ObjectMapper().readerFor(IncomingMessage.class); @@ -42,7 +48,8 @@ public class SQS { public SQS(SqsClient sqs, @ConfigProperty(name = "whatsapp.incoming-message.queue-url") String incomingMessagesUrl, @ConfigProperty(name = "whatsapp.recognized-message.queue-url") String messagesProcessedUrl, ObjectMapper objectMapper, TextAiService aiService, RecordRepository recordRepository, - UserRepository userRepository) { + UserRepository userRepository, OutboxRepository outboxRepository, + InboundMessageRepository inboundMessageRepository) { this.sqs = sqs; this.incomingMessagesUrl = incomingMessagesUrl; @@ -51,6 +58,8 @@ public SQS(SqsClient sqs, @ConfigProperty(name = "whatsapp.incoming-message.queu this.aiService = aiService; this.recordRepository = recordRepository; this.userRepository = userRepository; + this.outboxRepository = outboxRepository; + this.inboundMessageRepository = inboundMessageRepository; } @Scheduled(every = "5s") @@ -72,6 +81,12 @@ private void processMessage(String body, String receiptHandle) { return; } + if (inboundMessageRepository.findById(incomingMessage.messageId()) != null) { + logger.warnf("Message %s already processed. Deleting from queue.", incomingMessage.messageId()); + deleteMessageUsing(receiptHandle); + return; + } + handleUserMessage(user.get(), incomingMessage, receiptHandle); } @@ -100,15 +115,24 @@ private void handleUserMessage(User user, IncomingMessage message, String receip private void processAddTransactionMessage(User user, IncomingMessage message, String receiptHandle, RecognizedOperation recognizedOperation) throws IOException { RecognizedTransaction recognizedTransaction = recognizedOperation.recognizedTransaction(); - sendProcessedMessage(new TransactionMessageProcessed(AiOperations.ADD_TRANSACTION.commandName(), - message.messageId(), MessageStatus.PROCESSED, user.getPhoneNumber(), recognizedTransaction.withError(), - recognizedTransaction)); Record record = new Record.Builder().userId(user.getId()).amount(recognizedTransaction.amount()) .description(recognizedTransaction.description()).transaction(recognizedTransaction.type()) .category(recognizedTransaction.category()).build(); - QuarkusTransaction.requiringNew().run(() -> recordRepository.persist(record)); + TransactionMessageProcessed processedMessage = new TransactionMessageProcessed( + AiOperations.ADD_TRANSACTION.commandName(), message.messageId(), MessageStatus.PROCESSED, + user.getPhoneNumber(), recognizedTransaction.withError(), recognizedTransaction); + + QuarkusTransaction.requiringNew().run(() -> { + inboundMessageRepository.persist(new InboundMessage(message.messageId())); + recordRepository.persist(record); + try { + saveToOutbox(processedMessage); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + }); deleteMessageUsing(receiptHandle); @@ -119,16 +143,27 @@ private void processSimpleMessage(User user, IncomingMessage message, String rec RecognizedOperation recognizedOperation) throws IOException { logger.infof("Processing simple message for user %s", recognizedOperation.recognizedTransaction()); SimpleMessage response = new SimpleMessage(recognizedOperation.recognizedTransaction().description()); - sendProcessedMessage(new SimpleMessageProcessed(AiOperations.GET_BALANCE.commandName(), message.messageId(), - MessageStatus.PROCESSED, user.getPhoneNumber(), response)); + + SimpleMessageProcessed processedMessage = new SimpleMessageProcessed(AiOperations.GET_BALANCE.commandName(), + message.messageId(), MessageStatus.PROCESSED, user.getPhoneNumber(), response); + + QuarkusTransaction.requiringNew().run(() -> { + inboundMessageRepository.persist(new InboundMessage(message.messageId())); + try { + saveToOutbox(processedMessage); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + }); + deleteMessageUsing(receiptHandle); logger.infof("Message %s processed as GET_BALANCE", message.messageId()); } - private void sendProcessedMessage(Object processedMessage) throws JsonProcessingException { - String messageBody = objectMapper.writeValueAsString(processedMessage); - sqs.sendMessage(req -> req.messageBody(messageBody).messageGroupId("ProcessedMessages") - .messageDeduplicationId(UUID.randomUUID().toString()).queueUrl(processedMessagesUrl)); + private void saveToOutbox(Object processedMessage) throws JsonProcessingException { + String payload = objectMapper.writeValueAsString(processedMessage); + Outbox outbox = new Outbox(payload); + outboxRepository.persist(outbox); } private IncomingMessage parseIncomingMessage(String messageBody) { diff --git a/timeless-api/src/main/webui/package-lock.json b/timeless-api/src/main/webui/package-lock.json index cae08e8..7020eed 100644 --- a/timeless-api/src/main/webui/package-lock.json +++ b/timeless-api/src/main/webui/package-lock.json @@ -345,7 +345,6 @@ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.0.6.tgz", "integrity": "sha512-dSxhkh/ZlljdglZ0rriSy7GdC1Y3rGaagkx6oAzF5XqAoBbFmiVFEBZPxssSeQ+O0izmAw3GwsUnz3E/1JYsbA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -997,7 +996,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.0.6.tgz", "integrity": "sha512-Yd8PF0dR37FAzqEcBHAyVCiSGMJOezSJe6rV/4BC6AVLfaZ7oZLl8CNVxKsod2UHd6rKxt1hzx05QdVcVvYNeA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1014,7 +1012,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.0.6.tgz", "integrity": "sha512-rBMzG7WnQMouFfDST+daNSAOVYdtw560645PhlxyVeIeHMlCm0j1jjBgVPGTBNpVgKRdT/sqbi6W6JYkY9mERA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1028,7 +1025,6 @@ "integrity": "sha512-UcIUx+fbn0VLlCBCIYxntAzWG3zPRUo0K7wvuK0MC6ZFCWawgewx9SdLLZTqcaWe1g5FRQlQeVQcFgHAO5R2Mw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.4", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -1061,7 +1057,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.0.6.tgz", "integrity": "sha512-SvWbOkkrsqprYJSBmzQEWkWjfZB/jkRYyFp2ClMJBPqOLxP1a+i3Om2rolcNQjZPz87bs9FszwgRlXUy7sw5cQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1087,7 +1082,6 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.0.6.tgz", "integrity": "sha512-aAkAAKuUrP8U7R4aH/HbmG/CXP90GlML77ECBI5b4qCSb+bvaTEYsaf85mCyTpr9jvGkia2LTe42hPcOuyzdsQ==", "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0" @@ -1107,7 +1101,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.6.tgz", "integrity": "sha512-tPk8rlUEBPXIUPRYq6Xu7QhJgKtnVr0dOHHuhyi70biKTupr5VikpZC5X9dy2Q3H3zYbK6MHC6384YMuwfU2kg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1148,7 +1141,6 @@ "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.6.tgz", "integrity": "sha512-HOfomKq7jRSgxt/uUvpdbB8RNaYuGB/FJQ3BfQCFfGw1O9L3B72b7Hilk6AcjCruul6cfv/kmT4EB6Vqi3dQtA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1193,7 +1185,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2212,7 +2203,6 @@ "integrity": "sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.0", "@inquirer/confirm": "^5.1.19", @@ -4709,7 +4699,6 @@ "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5127,7 +5116,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5605,6 +5593,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "is-what": "^3.14.1" }, @@ -6047,6 +6036,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "prr": "~1.0.1" }, @@ -7098,6 +7088,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "bin": { "image-size": "bin/image-size.js" }, @@ -7302,7 +7293,8 @@ "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/isbinaryfile": { "version": "4.0.10", @@ -7424,15 +7416,13 @@ "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.13.0.tgz", "integrity": "sha512-vsYjfh7lyqvZX5QgqKc4YH8phs7g96Z8bsdIFNEU3VqXhlHaq+vov/Fgn/sr6MiUczdZkyXRC3TX369Ll4Nzbw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -7530,7 +7520,6 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -7859,6 +7848,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -7887,6 +7877,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "pify": "^4.0.1", "semver": "^5.6.0" @@ -7902,6 +7893,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "bin": { "mime": "cli.js" }, @@ -7916,6 +7908,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "bin": { "semver": "bin/semver" } @@ -7927,6 +7920,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7955,7 +7949,6 @@ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -8609,6 +8602,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" @@ -8627,6 +8621,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -9099,6 +9094,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.10" } @@ -9262,6 +9258,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -9317,7 +9314,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9542,7 +9538,8 @@ "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/punycode": { "version": "1.4.1", @@ -9890,7 +9887,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -9926,7 +9922,6 @@ "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -9948,7 +9943,8 @@ "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "dev": true, "license": "BlueOak-1.0.0", - "optional": true + "optional": true, + "peer": true }, "node_modules/semver": { "version": "7.7.3", @@ -10729,7 +10725,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -10941,8 +10936,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tuf-js": { "version": "4.1.0", @@ -10979,7 +10973,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11162,7 +11155,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -11466,7 +11458,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -11534,7 +11525,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -11553,8 +11543,7 @@ "version": "0.15.0", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.0.tgz", "integrity": "sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==", - "license": "MIT", - "peer": true + "license": "MIT" } } }