diff --git a/timeless-api/pom.xml b/timeless-api/pom.xml index b979a85..38a48d5 100644 --- a/timeless-api/pom.xml +++ b/timeless-api/pom.xml @@ -67,6 +67,10 @@ quarkus-langchain4j-openai ${quarkus-langchain4j-openai.version} + + io.quarkiverse.amazonservices + quarkus-messaging-amazon-sqs + io.quarkiverse.amazonservices quarkus-amazon-sqs diff --git a/timeless-api/src/main/java/dev/matheuscruz/infra/outbox/OutboxMessage.java b/timeless-api/src/main/java/dev/matheuscruz/infra/outbox/OutboxMessage.java new file mode 100644 index 0000000..310ea4a --- /dev/null +++ b/timeless-api/src/main/java/dev/matheuscruz/infra/outbox/OutboxMessage.java @@ -0,0 +1,101 @@ +package dev.matheuscruz.infra.outbox; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "outbox_messages") +public class OutboxMessage { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, columnDefinition = "TEXT") + private String payload; + + @Column(name = "queue_url", nullable = false) + private String queueUrl; + + @Column(name = "message_group_id", nullable = false) + private String messageGroupId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OutboxStatus status; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "processed_at") + private Instant processedAt; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + protected OutboxMessage() { + } + + public OutboxMessage(String payload, String queueUrl, String messageGroupId) { + this.payload = payload; + this.queueUrl = queueUrl; + this.messageGroupId = messageGroupId; + this.status = OutboxStatus.PENDING; + this.createdAt = Instant.now(); + this.retryCount = 0; + } + + public UUID getId() { + return id; + } + + public String getPayload() { + return payload; + } + + public String getQueueUrl() { + return queueUrl; + } + + public String getMessageGroupId() { + return messageGroupId; + } + + public OutboxStatus getStatus() { + return status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getProcessedAt() { + return processedAt; + } + + public int getRetryCount() { + return retryCount; + } + + public void markAsSent() { + this.status = OutboxStatus.SENT; + this.processedAt = Instant.now(); + } + + public void markAsFailed() { + this.status = OutboxStatus.FAILED; + this.processedAt = Instant.now(); + } + + public void incrementRetryCount() { + this.retryCount++; + } +} diff --git a/timeless-api/src/main/java/dev/matheuscruz/infra/outbox/OutboxMessageRelay.java b/timeless-api/src/main/java/dev/matheuscruz/infra/outbox/OutboxMessageRelay.java new file mode 100644 index 0000000..9f2d901 --- /dev/null +++ b/timeless-api/src/main/java/dev/matheuscruz/infra/outbox/OutboxMessageRelay.java @@ -0,0 +1,70 @@ +package dev.matheuscruz.infra.outbox; + +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; +import org.jboss.logging.Logger; +import software.amazon.awssdk.services.sqs.SqsClient; +import software.amazon.awssdk.services.sqs.model.SendMessageRequest; + +@ApplicationScoped +public class OutboxMessageRelay { + + private static final int MAX_RETRIES = 10; + + private final OutboxMessageRepository outboxMessageRepository; + private final SqsClient sqsClient; + private final Logger logger = Logger.getLogger(OutboxMessageRelay.class); + + public OutboxMessageRelay(OutboxMessageRepository outboxMessageRepository, SqsClient sqsClient) { + this.outboxMessageRepository = outboxMessageRepository; + this.sqsClient = sqsClient; + } + + @Scheduled(every = "5s") + public void processOutbox() { + List pending = outboxMessageRepository.findPendingMessages(); + + for (OutboxMessage message : pending) { + if (message.getRetryCount() >= MAX_RETRIES) { + failMessage(message); + continue; + } + + try { + sqsClient.sendMessage(SendMessageRequest.builder().queueUrl(message.getQueueUrl()) + .messageBody(message.getPayload()).messageGroupId(message.getMessageGroupId()).build()); + + updateMessageStatus(message, true); + } catch (Exception e) { + logger.errorf(e, "Failed to send outbox message %s to queue %s", message.getId(), + message.getQueueUrl()); + updateMessageStatus(message, false); + } + } + } + + private void updateMessageStatus(OutboxMessage message, boolean success) { + QuarkusTransaction.requiringNew().run(() -> { + OutboxMessage managed = outboxMessageRepository.findById(message.getId()); + if (managed != null) { + if (success) { + managed.markAsSent(); + } else { + managed.incrementRetryCount(); + } + } + }); + } + + private void failMessage(OutboxMessage message) { + QuarkusTransaction.requiringNew().run(() -> { + OutboxMessage managed = outboxMessageRepository.findById(message.getId()); + if (managed != null) { + managed.markAsFailed(); + logger.errorf("Outbox message %s has exceeded max retries, marking as FAILED", message.getId()); + } + }); + } +} diff --git a/timeless-api/src/main/java/dev/matheuscruz/infra/outbox/OutboxMessageRepository.java b/timeless-api/src/main/java/dev/matheuscruz/infra/outbox/OutboxMessageRepository.java new file mode 100644 index 0000000..0a00257 --- /dev/null +++ b/timeless-api/src/main/java/dev/matheuscruz/infra/outbox/OutboxMessageRepository.java @@ -0,0 +1,18 @@ +package dev.matheuscruz.infra.outbox; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; +import java.util.UUID; + +@ApplicationScoped +public class OutboxMessageRepository implements PanacheRepositoryBase { + + private static final int MAX_RETRIES = 10; + private static final int BATCH_SIZE = 20; + + public List findPendingMessages() { + return find("status = ?1 and retryCount < ?2 order by createdAt", OutboxStatus.PENDING, MAX_RETRIES) + .range(0, BATCH_SIZE - 1).list(); + } +} diff --git a/timeless-api/src/main/java/dev/matheuscruz/infra/outbox/OutboxStatus.java b/timeless-api/src/main/java/dev/matheuscruz/infra/outbox/OutboxStatus.java new file mode 100644 index 0000000..d3e8be8 --- /dev/null +++ b/timeless-api/src/main/java/dev/matheuscruz/infra/outbox/OutboxStatus.java @@ -0,0 +1,5 @@ +package dev.matheuscruz.infra.outbox; + +public enum OutboxStatus { + PENDING, SENT, FAILED +} 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..a815d9a 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 @@ -13,122 +13,126 @@ import dev.matheuscruz.infra.ai.data.RecognizedOperation; import dev.matheuscruz.infra.ai.data.RecognizedTransaction; import dev.matheuscruz.infra.ai.data.SimpleMessage; +import dev.matheuscruz.infra.outbox.OutboxMessage; +import dev.matheuscruz.infra.outbox.OutboxMessageRepository; import io.quarkus.narayana.jta.QuarkusTransaction; -import io.quarkus.scheduler.Scheduled; import jakarta.enterprise.context.ApplicationScoped; -import java.io.IOException; import java.util.Optional; -import java.util.UUID; +import java.util.concurrent.CompletionStage; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Message; import org.jboss.logging.Logger; -import software.amazon.awssdk.services.sqs.SqsClient; @ApplicationScoped public class SQS { - final String incomingMessagesUrl; - final String processedMessagesUrl; - final SqsClient sqs; final ObjectMapper objectMapper; final TextAiService aiService; final RecordRepository recordRepository; final UserRepository userRepository; + final OutboxMessageRepository outboxMessageRepository; + final String recognizedQueueUrl; final Logger logger = Logger.getLogger(SQS.class); private static final ObjectReader INCOMING_MESSAGE_READER = new ObjectMapper().readerFor(IncomingMessage.class); - private static final ObjectReader AI_RESPONSE_READER = new ObjectMapper().readerFor(RecognizedOperation.class); + public SQS(ObjectMapper objectMapper, TextAiService aiService, RecordRepository recordRepository, + UserRepository userRepository, OutboxMessageRepository outboxMessageRepository, + @ConfigProperty(name = "whatsapp.recognized-message.queue-url") String recognizedQueueUrl) { - 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) { - - this.sqs = sqs; - this.incomingMessagesUrl = incomingMessagesUrl; - this.processedMessagesUrl = messagesProcessedUrl; this.objectMapper = objectMapper; this.aiService = aiService; this.recordRepository = recordRepository; this.userRepository = userRepository; + this.outboxMessageRepository = outboxMessageRepository; + this.recognizedQueueUrl = recognizedQueueUrl; } - @Scheduled(every = "5s") - public void receiveMessages() { - sqs.receiveMessage(req -> req.maxNumberOfMessages(10).queueUrl(incomingMessagesUrl)).messages() - .forEach(message -> processMessage(message.body(), message.receiptHandle())); - } - - private void processMessage(String body, String receiptHandle) { + @Incoming("whatsapp-incoming") + public CompletionStage receiveMessages(Message message) { + String body = message.getPayload(); IncomingMessage incomingMessage = parseIncomingMessage(body); - if (!MessageKind.TEXT.equals(incomingMessage.kind())) - return; + + if (!MessageKind.TEXT.equals(incomingMessage.kind())) { + return message.ack(); + } Optional user = this.userRepository.findByPhoneNumber(incomingMessage.sender()); if (user.isEmpty()) { - logger.error("User not found. Deleting message from queue."); - deleteMessageUsing(receiptHandle); - return; + logger.error("User not found."); + return message.nack(new RuntimeException("User not found for phone: " + incomingMessage.sender())); } - handleUserMessage(user.get(), incomingMessage, receiptHandle); + try { + handleUserMessage(user.get(), incomingMessage); + return message.ack(); + } catch (Exception e) { + logger.error("Failed to process message: " + incomingMessage.messageId(), e); + return message.nack(e); + } } - private void handleUserMessage(User user, IncomingMessage message, String receiptHandle) { - try { - AllRecognizedOperations allRecognizedOperations = aiService.handleMessage(message.messageBody(), - user.getId()); - - for (RecognizedOperation recognizedOperation : allRecognizedOperations.all()) { - switch (recognizedOperation.operation()) { - case AiOperations.ADD_TRANSACTION -> - processAddTransactionMessage(user, message, receiptHandle, recognizedOperation); - case AiOperations.GET_BALANCE -> { - logger.info("Processing GET_BALANCE operation" + recognizedOperation.recognizedTransaction()); - processSimpleMessage(user, message, receiptHandle, recognizedOperation); - } - default -> logger.warnf("Unknown operation type: %s", recognizedOperation.operation()); + private void handleUserMessage(User user, IncomingMessage message) { + AllRecognizedOperations allRecognizedOperations = aiService.handleMessage(message.messageBody(), user.getId()); + + for (RecognizedOperation recognizedOperation : allRecognizedOperations.all()) { + switch (recognizedOperation.operation()) { + case AiOperations.ADD_TRANSACTION -> processAddTransactionMessage(user, message, recognizedOperation); + case AiOperations.GET_BALANCE -> { + logger.info("Processing GET_BALANCE operation" + recognizedOperation.recognizedTransaction()); + processSimpleMessage(user, message, recognizedOperation); } + default -> logger.warnf("Unknown operation type: %s", recognizedOperation.operation()); } - - } catch (Exception e) { - logger.error("Failed to process message: " + message.messageId(), e); } } - private void processAddTransactionMessage(User user, IncomingMessage message, String receiptHandle, - RecognizedOperation recognizedOperation) throws IOException { + private void processAddTransactionMessage(User user, IncomingMessage message, + RecognizedOperation recognizedOperation) { 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 processed = new TransactionMessageProcessed( + AiOperations.ADD_TRANSACTION.commandName(), message.messageId(), MessageStatus.PROCESSED, + user.getPhoneNumber(), recognizedTransaction.withError(), recognizedTransaction); + + OutboxMessage outboxMessage = new OutboxMessage(serialize(processed), recognizedQueueUrl, + user.getPhoneNumber()); - deleteMessageUsing(receiptHandle); + QuarkusTransaction.requiringNew().run(() -> { + recordRepository.persist(record); + outboxMessageRepository.persist(outboxMessage); + }); logger.infof("Message %s processed as ADD_TRANSACTION", message.messageId()); } - private void processSimpleMessage(User user, IncomingMessage message, String receiptHandle, - RecognizedOperation recognizedOperation) throws IOException { + private void processSimpleMessage(User user, IncomingMessage message, RecognizedOperation recognizedOperation) { 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)); - deleteMessageUsing(receiptHandle); + + SimpleMessageProcessed processed = new SimpleMessageProcessed(AiOperations.GET_BALANCE.commandName(), + message.messageId(), MessageStatus.PROCESSED, user.getPhoneNumber(), response); + + OutboxMessage outboxMessage = new OutboxMessage(serialize(processed), recognizedQueueUrl, + user.getPhoneNumber()); + + QuarkusTransaction.requiringNew().run(() -> outboxMessageRepository.persist(outboxMessage)); + 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 String serialize(Object message) { + try { + return objectMapper.writeValueAsString(message); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize message", e); + } } private IncomingMessage parseIncomingMessage(String messageBody) { @@ -139,10 +143,6 @@ private IncomingMessage parseIncomingMessage(String messageBody) { } } - private void deleteMessageUsing(String receiptHandle) { - sqs.deleteMessage(req -> req.queueUrl(incomingMessagesUrl).receiptHandle(receiptHandle)); - } - public record TransactionMessageProcessed(String kind, String messageId, MessageStatus status, String user, Boolean withError, RecognizedTransaction content) { } diff --git a/timeless-api/src/main/resources/application.properties b/timeless-api/src/main/resources/application.properties index 219f81e..b1c1a0d 100644 --- a/timeless-api/src/main/resources/application.properties +++ b/timeless-api/src/main/resources/application.properties @@ -5,6 +5,11 @@ security.sensible.secret=${SECURITY_KEY} whatsapp.incoming-message.queue-url=${INCOMING_MESSAGE_FIFO_URL} whatsapp.recognized-message.queue-url=${RECOGNIZED_MESSAGE_FIFO_URL} +# smallrye reactive messaging sqs +mp.messaging.incoming.whatsapp-incoming.connector=smallrye-sqs +mp.messaging.incoming.whatsapp-incoming.queue=${INCOMING_MESSAGE_FIFO_URL} +mp.messaging.incoming.whatsapp-incoming.visibility-timeout=30 + # aws sqs quarkus.sqs.devservices.enabled=false diff --git a/timeless-api/src/main/webui/package-lock.json b/timeless-api/src/main/webui/package-lock.json index d9dc410..53c8807 100644 --- a/timeless-api/src/main/webui/package-lock.json +++ b/timeless-api/src/main/webui/package-lock.json @@ -348,7 +348,6 @@ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.12.tgz", "integrity": "sha512-91mgQI15qStL38LijoKyAvNo61wB5rUpwqDVHoJQeISUChVYOY4hiofO6hW6ERg8MHQKUTyOrPDg5cN4yTcp9A==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -516,7 +515,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.12.tgz", "integrity": "sha512-b7IRSM9fWPmZ1SLN0utVcW87IkhiRte3Wsnwr2nEsjum2soRMfvKqHwtEFGfCztlwOmZLgKiGW9pqKpzBkIjnQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -533,7 +531,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.12.tgz", "integrity": "sha512-246iBwMAVGzrYPqu/Wwzb9L/kt+dkT12Hllr/dYZu6aHeIxaHPRZoPBKSweAgOPXeOl+q+nlPtK34glsMb1CRw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -547,7 +544,6 @@ "integrity": "sha512-YQ15Yp2OWBS1NnzZH77HLH1ZDn+/A5Mc1EobKl4CX8dYUEPIB/KwmGKLaKtbJ0KNcVsDlmsTTWodRgqe2n5erw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.29.0", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -580,7 +576,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.12.tgz", "integrity": "sha512-wcD6tzE30nwg58KmAU19347Jf/1F/vFg2CEd9Qcu5cA1Z4s3umzvaqs/7988ne4HaS4iJEpvTbRvGss7EYZEfA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -606,7 +601,6 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.12.tgz", "integrity": "sha512-jhHaIgMWcgPcVFEPwhjLhByvA2xou6Th5PR6iC3H0YeLQyRmOFPWdczszytlWB1CeJ0UT9epxzOZT25zNcGSfg==", "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0" @@ -626,7 +620,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.12.tgz", "integrity": "sha512-P4MVColcYgBPmHyQ9nPVw9NjWPNxkC++N2Bjh3kOUFflC/6D/ufYJytsI/y1WQ8dtoHPHxiuRf3xHvcwUMPgEQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -667,7 +660,6 @@ "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.12.tgz", "integrity": "sha512-2/RDHt3GdW2ABNRVrgLX7IxgJLdF7u8Sbh11kAUn04QhNI/GObxIV4M5Hm/NTeDoi+hCXavkaHVBlj/dG5ANbw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -712,7 +704,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -985,6 +976,31 @@ "node": ">=0.1.90" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -992,6 +1008,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1705,7 +1722,6 @@ "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.2", "@inquirer/confirm": "^5.1.21", @@ -3942,7 +3958,6 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -4333,7 +4348,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4541,7 +4555,6 @@ "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^5.0.0" }, @@ -5409,7 +5422,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5896,7 +5908,6 @@ "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -6391,15 +6402,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" } @@ -6504,7 +6513,6 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -6987,7 +6995,6 @@ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -8230,7 +8237,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8808,7 +8814,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" } @@ -9436,7 +9441,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -9644,8 +9648,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", @@ -9683,7 +9686,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9829,7 +9831,6 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10157,7 +10158,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -10176,8 +10176,7 @@ "version": "0.15.1", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", - "license": "MIT", - "peer": true + "license": "MIT" } } }